diff --git a/.changeset/15484-rest-log-declared-level-seam.md b/.changeset/15484-rest-log-declared-level-seam.md new file mode 100644 index 0000000000..784144d4b2 --- /dev/null +++ b/.changeset/15484-rest-log-declared-level-seam.md @@ -0,0 +1,15 @@ +--- +"@objectstack/rest": patch +--- + +`packages/rest`'s fault logging gains a **declared level seam**, `OS_REST_LOG`, with the **shipped default unchanged**. At the default — and an unset or unrecognised value *is* the default — a reported fault still prints the whole `Error`: message, `cause` chain and stack frames, exactly as before. ⛔ No wire byte moves, no published payload gains a key, and no existing log line changes shape. + +What is new is that the loud/quiet choice is now **declared and machine-read** instead of implicit in whether an author happened to pass `error` or `error.message`: + +- **`OS_REST_LOG`** accepts `debug` / `info` / `warn` / `error` / `silent` — deliberately the same vocabulary and the same `'info'` default as `@objectstack/objectql`'s `OS_REGISTRY_LOG`, so the two are one logging contract with two populations rather than a second ad-hoc environment variable. Documented for operators in this package's README. +- **`scripts/check-rest-log-declared.mjs`** enforces it: the seam is located by its environment read (never a hardcoded path), the vocabulary is read from `REST_LOG_LEVELS` rather than copied, the two seams' vocabularies are held equal, a harness declaration must name a level the seam actually recognises — an unrecognised one resolves to the default *silently* — and every inline vitest project must carry its own declaration, because a root-level `env` is inert for project runs. +- **The shipped default is gated, not just documented.** Lowering `REST_LOG_DEFAULT_LEVEL` to `error` or `silent` is a finding, because at those levels this package stops reporting faults it is the only reporter of. + +**Why the default does not move.** Measured on one green `packages/rest` run: 2,095 indented `at ` frame lines, 36.7% of captured output, 100% of them arriving through this one shim. They are not dead weight. When a 5xx is withheld from the client, the log is the only copy of the driver text, and that text lives on `error.cause` — printed only because a whole `Error` object, not a summary, reaches `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` pin that by asserting the **identity** of the error that arrives, one of them carrying an explicit do-not-delete warning aimed at exactly this repair. + +Operators: nothing to do. A deployment that wants the REST layer quieter can now say so — `OS_REST_LOG=error` drops the warning half, `silent` drops both — but doing so discards diagnostics that have no second copy anywhere, and the README says so at the seam. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 21b8a8350f..eba2d4219b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3762,6 +3762,19 @@ jobs: - name: Declared registry log level run: node scripts/check-registry-log-declared.mjs --self-test && node scripts/check-registry-log-declared.mjs + # The same defect one package down (#15484). `packages/rest`'s own fault + # logging hands whole `Error` objects to `console.error`, and Node prints + # the stack and the `[cause]` chain with them — 2,095 indented `at ` frame + # lines on one green run, 36.7% of the captured output, all of it through + # one shim. ⛔ Those frames are READ (four assertions pin the identity of + # the error that arrives, #5437 / #4886 / #5489), so the ruled repair was a + # DECLARED level, not a quieter product. This holds the two halves that rot + # silently: a harness declaration that is deleted or typo'd to a level the + # seam does not recognise (it resolves to the DEFAULT, silently), and the + # shipped default itself being lowered to quieten a log. Self-test first. + - name: Declared REST fault-log level + run: node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs + # Live-server database isolation (#10382). CI provisions ONE Postgres and # ONE MySQL for the whole temporal-conformance job and points every live # leg at them, and every live suite in the repo issues a `drop` when it diff --git a/docs/audits/2026-09-test-log-volume-census.md b/docs/audits/2026-09-test-log-volume-census.md index 3e47348554..c7ef45d7e0 100644 --- a/docs/audits/2026-09-test-log-volume-census.md +++ b/docs/audits/2026-09-test-log-volume-census.md @@ -27,9 +27,12 @@ total volume than the five heaviest suites already were. See - **No urgency, no correctness impact.** This is CI log volume. The earlier reading said so and nothing here changes it. -- **No seam was added, and none is recommended.** The two candidates — an env - read in the kernel logger, a level field on `BootOptions` — both touch - published surface, and choosing between them is not a measurement. +- **No seam was added by this document, and neither candidate it listed is + recommended.** The two candidates — an env read in the kernel logger, a level + field on `BootOptions` — both touch published surface, and choosing between + them is not a measurement. ⚠️ A different seam was later ruled and built for + a different population: `OS_REST_LOG`, on `packages/rest`'s own fault logging + (#15484). See the closing section — that is not one of these two. - **"A reader of a production boot log may well want every one of these lines."** Test-environment noise and production observability are two ends of one switch. Nothing here is an argument for lowering the engine's boot INFO @@ -416,6 +419,18 @@ point does the combined population cross back toward parity, let alone toward `console` being the majority — it stays firmly structured-dominated (77.1%) throughout. -**No seam was added.** Per triage's ruling, this document is the measurement -only; which of the two candidate seams (if either) to build is triage's call, -made with this table in hand. +**No seam was added by this document, and the reservation it held is now +DISCHARGED.** This document was the measurement only, and it reserved to triage +「which of the two candidate seams (if either) to build」. The maintainer decided +it in decision batch #49, item 2 (2026-09-05, recorded on #15484): option **A**, +a declared level seam on `packages/rest`'s `logError`, in the `OS_REGISTRY_LOG` +shape, **with the shipped default unchanged**. + +⇒ The seam that was built against that ruling is `OS_REST_LOG` +(`packages/rest/src/log.ts`, `REST_LOG_LEVELS`), enforced by +`scripts/check-rest-log-declared.mjs`. ⛔ It is a DECLARATION, not a quieter +product: at the shipped default a reported fault still prints the whole `Error` +— message, `cause` chain and frames — for every real caller, and suppression is +only ever something a harness declares. The two candidates this document listed +(an env read in the kernel logger, a level field on `BootOptions`) remain +unbuilt; neither was chosen. diff --git a/package.json b/package.json index 10a78c038c..ba3d9b9ae9 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "examples:live-imports": "node scripts/check-examples-live-imports.mjs --list", "check:test-source-alias": "node scripts/check-test-source-alias.mjs --self-test && node scripts/check-test-source-alias.mjs", "check:registry-log-declared": "node scripts/check-registry-log-declared.mjs --self-test && node scripts/check-registry-log-declared.mjs", + "check:rest-log-declared": "node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs", "check:refd-timer-probe": "node scripts/check-refd-timer-probe.mjs --self-test && node scripts/check-refd-timer-probe.mjs", "check:type-source-resolution": "node scripts/check-type-source-resolution.mjs --self-test && node scripts/check-type-source-resolution.mjs", "check:undeclared-dep-imports": "node scripts/check-undeclared-dep-imports.mjs --self-test && node scripts/check-undeclared-dep-imports.mjs", diff --git a/packages/rest/README.md b/packages/rest/README.md index 265c193e8e..a8d48b3aa0 100644 --- a/packages/rest/README.md +++ b/packages/rest/README.md @@ -97,6 +97,29 @@ Plus metadata and discovery routes: | `caching.etag` | `boolean` | `true` | Emits `ETag` header. | | `caching.lastModified` | `boolean` | `true` | Emits `Last-Modified`. | +### Environment + +| Variable | Values | Default | Notes | +|:---|:---|:---|:---| +| `OS_REST_LOG` | `debug` \| `info` \| `warn` \| `error` \| `silent` | `info` | Level for this package's own fault logging. | + +`OS_REST_LOG` declares how loud the REST layer is about faults **it reports +itself** — the `[REST] …` lines written when a request fails. It is the same +vocabulary, and the same shipped default, as `@objectstack/objectql`'s +`OS_REGISTRY_LOG`; an unrecognised value falls back to the default rather than +silencing anything. + +At the default a reported fault prints the **whole** `Error`: its message, its +`cause` chain and its stack frames. That is deliberate and it is the reason to +leave it alone. When a 5xx is withheld from the client, the log is the only +place the underlying driver text exists, and that text travels on `error.cause` +— it is printed only because a whole `Error`, not a summary, reaches the +console. Lowering the level to `error` or `silent` discards diagnostics that +have no second copy anywhere. + +⇒ Prefer declaring a quieter level in a **test harness** (`vitest.config.ts`'s +`env` block) over exporting it for a running server. + ## HTTP semantics - JSON envelope: `{ success, data, error?, meta? }`. diff --git a/packages/rest/src/log.ts b/packages/rest/src/log.ts index 5252da904a..3720e0a077 100644 --- a/packages/rest/src/log.ts +++ b/packages/rest/src/log.ts @@ -9,8 +9,96 @@ * is the "two spellings of one thing" shape this repo pays for repeatedly. It * is deliberately NOT re-exported from the package index: an internal shim, not * a logging API. + * + * ── The declared level seam (#15484) ────────────────────────────────────── + * + * Every fault this package reports goes through `logError`, and `logError` + * hands its varargs — an `Error` object among them — straight to + * `console.error`, which Node formats with the error's full stack and its + * `[cause]` chain. Measured on one green `packages/rest` run: 2,095 indented + * `at ` frame lines, 36.7% of the captured output, and 100% of them arrive + * through this file (1,197 from `error-response.ts`, 841 from + * `rest-server.ts`, 57 from `cause` chains). + * + * ⛔ That volume is NOT a defect and the frames are NOT dead weight. They are + * read, and the repo pins that they are read: at `logWithheldServerFault` the + * client is told nothing and the log is the operator's only copy of the driver + * text — which lives on `error.cause` and is printed only because a whole + * `Error` is passed; at `logUnexpectedRouteError` the frames are the only + * location diagnostic a bare `TypeError` has. Four assertions across + * `rest-5xx-message-sanitization.test.ts` and `rest-expected-error-logging.test.ts` + * hold that, by asserting the IDENTITY of the `Error` reaching `console.error` + * (#5437 / #4886 / #5489). ⛔ Do not "quieten" this shim by formatting the + * `Error` down to a string or a summary — that is the repair those pins exist + * to stop, and it deletes from the LOG what was deliberately withheld from the + * CLIENT. + * + * So the seam is a DECLARATION, not a quieter product: `OS_REST_LOG` names a + * level, exactly as `OS_REGISTRY_LOG` does for `@objectstack/objectql`'s + * `SchemaRegistry`, with the same five-level vocabulary and the same shipped + * `'info'` default. ⛔ The shipped default is unchanged and must stay + * unchanged: a reported fault keeps printing the full `Error` — message, + * `cause` chain and frames — for every real caller. Suppression is only ever + * something a HARNESS opts into, declared where a gate can read it + * (`scripts/check-rest-log-declared.mjs`), never the product's default. + * + * An operator learns the variable from `packages/rest/README.md`, from this + * block, and from `docs/audits/2026-09-test-log-volume-census.md`; an + * unrecognised value falls back to the default rather than silently silencing + * anything, which is the same failure direction `OS_REGISTRY_LOG` chose. + */ + +/** + * The levels `OS_REST_LOG` accepts — deliberately the same vocabulary as + * `@objectstack/objectql`'s `REGISTRY_LOG_LEVELS`, so the two declarations are + * one contract with two populations rather than two ad-hoc environment + * variables. Ordered loudest-first; `scripts/check-rest-log-declared.mjs` + * READS this array rather than copying it. + */ +export const REST_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'] as const; + +/** One of {@link REST_LOG_LEVELS}. */ +export type RestLogLevel = (typeof REST_LOG_LEVELS)[number]; + +/** + * The SHIPPED default — what every real caller gets when `OS_REST_LOG` is + * unset or unrecognised. ⛔ Never lower this to quieten a log: at any level + * below `'warn'` this package stops reporting faults it is the only reporter + * of. `rest-log-declared-level-seam.test.ts` pins it. + */ +export const REST_LOG_DEFAULT_LEVEL: RestLogLevel = 'info'; + +/** Emission threshold per level: a site emits when its own rank <= the level's. */ +const LEVEL_RANK: Readonly> = { + debug: 4, info: 3, warn: 2, error: 1, silent: 0, +}; + +/** `logError`'s rank, and `logWarn`'s — a site speaks while the level reaches it. */ +const ERROR_RANK = LEVEL_RANK.error; +const WARN_RANK = LEVEL_RANK.warn; + +/** + * The level in force right now. + * + * Read from the environment on every call, not memoised at module load: a + * harness that declares the level through vitest's `env` block, and a test that + * restores the shipped default around one assertion, both have to be observed + * by a module that may already be imported. This is a fault path — it runs once + * per reported fault, never per request. */ +export function restLogLevel(): RestLogLevel { + const raw = String((globalThis as any).process?.env?.OS_REST_LOG ?? '').toLowerCase(); + return (REST_LOG_LEVELS as readonly string[]).includes(raw) + ? (raw as RestLogLevel) + : REST_LOG_DEFAULT_LEVEL; +} // Node-safe logger — avoids importing 'console' which is absent from ES2020 lib typings. -export const logError = (...args: unknown[]) => (globalThis as any).console?.error(...args); -export const logWarn = (...args: unknown[]) => ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args); +export const logError = (...args: unknown[]) => { + if (LEVEL_RANK[restLogLevel()] < ERROR_RANK) return; + (globalThis as any).console?.error(...args); +}; +export const logWarn = (...args: unknown[]) => { + if (LEVEL_RANK[restLogLevel()] < WARN_RANK) return; + ((globalThis as any).console?.warn ?? (globalThis as any).console?.error)?.(...args); +}; diff --git a/packages/rest/src/rest-log-declared-level-seam.test.ts b/packages/rest/src/rest-log-declared-level-seam.test.ts new file mode 100644 index 0000000000..922fb8f3da --- /dev/null +++ b/packages/rest/src/rest-log-declared-level-seam.test.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#15484] The declared fault-log level seam on this package's console shim. +// +// The card this closes measured 2,095 indented `at ` stack-frame lines on one +// green `packages/rest` run — 36.7% of the captured output — and attributed +// 100% of them to `logError` handing whole `Error` objects to `console.error`, +// which Node formats with the full stack and the `[cause]` chain. +// +// The ruling on it (decision batch #49, item 2) bought a DECLARATION, not a +// quieter product: 「a declared level seam on `logError`, shipped default +// unchanged」. So the property this file exists to hold is the one that is +// easiest to lose by accident and impossible to notice afterwards: +// +// ⛔ THE SHIPPED DEFAULT PRINTS THE WHOLE `Error`. +// +// That matters because at `logWithheldServerFault` the client is told nothing +// and the log is the operator's ONLY copy of the driver text — and that text +// lives on `error.cause`, so it is printed only because an `Error` OBJECT, not +// a string, reaches `console.error` (#5437 / #8136). A "quieten the logs" +// change that lowers the default, or formats the error down to its message, +// deletes from the LOG exactly what was deliberately withheld from the CLIENT, +// and every existing assertion about the WIRE answer stays green while it does. +// +// ⚠️ This file asserts on the fault log, so it DECLARES the level it depends on +// instead of inheriting it from `vitest.config.ts`. That is the pattern any +// future test asserting on this shim should copy: see the measured note in the +// root `env` block of that config for why the suite's own value is the shipped +// default and not a quieter one. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + logError, + logWarn, + restLogLevel, + REST_LOG_LEVELS, + REST_LOG_DEFAULT_LEVEL, + type RestLogLevel, +} from './log.js'; + +let errorSpy: ReturnType; +let warnSpy: ReturnType; +let saved: string | undefined; + +/** Set the seam for one assertion; `undefined` means "as shipped — nothing declared". */ +function at(level: string | undefined): void { + if (level === undefined) delete process.env.OS_REST_LOG; + else process.env.OS_REST_LOG = level; +} + +beforeEach(() => { + saved = process.env.OS_REST_LOG; + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + if (saved === undefined) delete process.env.OS_REST_LOG; + else process.env.OS_REST_LOG = saved; + errorSpy.mockRestore(); + warnSpy.mockRestore(); +}); + +describe('[#15484] the shipped default is unchanged — a reported fault prints the whole Error', () => { + it('with NOTHING declared, logError hands console.error the identical argument list', () => { + at(undefined); + const cause = new Error('SQLITE_ERROR: no such table: sys_metadata'); + const boom = new Error('Failed to delete customization overlay', { cause }); + + logError('[REST] Unhandled error:', boom); + + expect(errorSpy).toHaveBeenCalledTimes(1); + const args = errorSpy.mock.calls[0]; + expect(args).toHaveLength(2); + expect(args[0]).toBe('[REST] Unhandled error:'); + // Identity, not shape: Node prints the frames and the `[cause]` chain + // only because the Error OBJECT itself is what arrives here. + expect(args[1]).toBe(boom); + expect((args[1] as Error).cause).toBe(cause); + }); + + it('an UNRECOGNISED value falls back to the default rather than silencing anything', () => { + at('quiet'); + expect(restLogLevel()).toBe(REST_LOG_DEFAULT_LEVEL); + logError('[REST] Unhandled error:', new Error('boom')); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('an empty declaration is not a silencing declaration', () => { + at(''); + expect(restLogLevel()).toBe(REST_LOG_DEFAULT_LEVEL); + logError('[REST] Unhandled error:', new Error('boom')); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('the declared default is loud enough for BOTH sites — the ⛔ that must not be lowered', () => { + at(undefined); + logError('e'); + logWarn('w'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('[#15484] the level ladder — suppression is only ever what a harness declares', () => { + it('silent stops both sites', () => { + at('silent'); + logError('e', new Error('boom')); + logWarn('w'); + expect(errorSpy).not.toHaveBeenCalled(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('error keeps the fault and drops the warning', () => { + at('error'); + logError('e', new Error('boom')); + logWarn('w'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it.each(['warn', 'info', 'debug'] as const)('%s keeps both sites, Error identity intact', (level) => { + at(level); + const boom = new Error('boom'); + logError('e', boom); + logWarn('w'); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(errorSpy.mock.calls[0][1]).toBe(boom); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it('every declared level is honoured by restLogLevel', () => { + for (const level of REST_LOG_LEVELS) { + at(level); + expect(restLogLevel()).toBe(level); + } + }); + + it('the level is read per call, so a harness declaration made after import is observed', () => { + at('silent'); + logError('e'); + expect(errorSpy).not.toHaveBeenCalled(); + at('info'); + logError('e'); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('an UPPERCASE declaration is honoured, not silently defaulted', () => { + at('SILENT'); + expect(restLogLevel()).toBe('silent' satisfies RestLogLevel); + }); +}); + +describe('[#15484] one logging contract, two populations', () => { + it('OS_REST_LOG accepts exactly the vocabulary OS_REGISTRY_LOG accepts', () => { + // The ruling asked for 「one logging contract, not a second ad-hoc env + // var」, so the two seams share one vocabulary. ⚠️ The EQUALITY is held + // by `scripts/check-rest-log-declared.mjs`, which reads both arrays out + // of their own sources, and NOT here: `@objectstack/objectql` does not + // re-export `REGISTRY_LOG_LEVELS` from its package index, so importing + // it here yields `undefined` and an assertion that cannot fail honestly. + // What this pin holds is the literal vocabulary, so a level added or + // renamed here has to be a deliberate edit in two places. + expect([...REST_LOG_LEVELS].sort()).toEqual(['debug', 'error', 'info', 'silent', 'warn']); + }); + + it('the shipped default matches the engine seam it is modelled on', () => { + expect(REST_LOG_DEFAULT_LEVEL).toBe('info'); + }); +}); diff --git a/packages/rest/vitest.config.ts b/packages/rest/vitest.config.ts index 634421747c..84331960cd 100644 --- a/packages/rest/vitest.config.ts +++ b/packages/rest/vitest.config.ts @@ -28,7 +28,11 @@ export default defineConfig({ // #13517: quiet the registry's per-item registration chatter — the // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped // default. Enforced by scripts/check-registry-log-declared.mjs. - env: { OS_REGISTRY_LOG: 'warn' }, + // #15484: `OS_REST_LOG` is this package's OWN declared fault-log level + // seam (packages/rest/src/log.ts). A ROOT-level value is inert for a + // project run, so it is declared here too. See the root block for the + // measured reason the value is the shipped default and not a quieter one. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'info' }, // A late console.* must not redden a green suite (#10374); see the root // block. A ROOT-level value is inert for a project run, so it is // declared here as well (scripts/check-console-intercept-disarm.mjs). @@ -44,7 +48,11 @@ export default defineConfig({ // #13517: quiet the registry's per-item registration chatter — the // engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped // default. Enforced by scripts/check-registry-log-declared.mjs. - env: { OS_REGISTRY_LOG: 'warn' }, + // #15484: `OS_REST_LOG` is this package's OWN declared fault-log level + // seam (packages/rest/src/log.ts). A ROOT-level value is inert for a + // project run, so it is declared here too. See the root block for the + // measured reason the value is the shipped default and not a quieter one. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'info' }, // A late console.* must not redden a green suite (#10374); see the root // block. A ROOT-level value is inert for a project run, so it is // declared here as well (scripts/check-console-intercept-disarm.mjs). @@ -71,7 +79,34 @@ export default defineConfig({ // The ADR-0005 `[Registry] Collision` diagnostics go through a bare // `console.warn` the level never gates, so a real shadowing still speaks. // Enforced by scripts/check-registry-log-declared.mjs. - env: { OS_REGISTRY_LOG: 'warn' }, + // #15484: `OS_REST_LOG` — this package's own declared fault-log level seam + // (`packages/rest/src/log.ts`, `REST_LOG_LEVELS`), enforced by + // `scripts/check-rest-log-declared.mjs`. Declared here at `'info'`, which is + // the SHIPPED default: the declaration is the deliverable, the value is a + // one-line choice, and this suite's value is deliberately NOT a quieter one. + // + // ⚠️ MEASURED, on this suite, before choosing it. `OS_REST_LOG: 'silent'` + // does remove the whole population this seam was built for — 2,095 indented + // `at ` frame lines, 36.7% of a captured run, to ZERO — but it is not a + // volume tidy, because it moves this suite's fault-logging assertions in two + // opposite and equally wrong directions at once: + // + // * 28 assertions across 15 files go RED. They are the "the operator still + // gets the words" half of the contract, and they read the fault through a + // `vi.spyOn(console, 'error')` mock — so they never printed any of the + // volume in the first place. + // * 8 files assert the OTHER half — that an EXPECTED 4xx logs NOTHING + // (`expect(unhandledLogs()).toHaveLength(0)` and siblings). Silencing the + // shim makes those pass for the wrong reason: they would stay green with + // every expected 4xx logged loudly. That is the phantom-check shape this + // repo refuses, arrived at by a legitimate-looking declaration — exactly + // how the `[Registry]` control on #15484 was silently spent by #15425. + // + // ⇒ Opting this suite down needs each file that asserts on the fault log to + // declare the loud level for itself, and needs a guard that pairs the two so + // a future test cannot assert silence into a silenced suite. That is a + // decision about ~20 files, and it is open on #15484 rather than taken here. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'info' }, globals: true, environment: 'node', }, diff --git a/scripts/check-rest-log-declared.mjs b/scripts/check-rest-log-declared.mjs new file mode 100644 index 0000000000..cd8e3253d1 --- /dev/null +++ b/scripts/check-rest-log-declared.mjs @@ -0,0 +1,623 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-rest-log-declared — the package that owns the REST fault-log level +// seam must DECLARE that level in its own vitest config, the declared value +// must be one the seam actually recognises, and the SHIPPED default must stay +// loud enough to report a fault. +// +// ── The defect this keeps closed (#15484, origin #13517 / #15426) ─────────── +// +// `packages/rest/src/log.ts`'s `logError` hands its varargs — an `Error` among +// them — to `console.error`, and Node formats an `Error` argument with its full +// stack and its `[cause]` chain. Measured on one green `packages/rest` run: +// 2,095 indented `at ` frame lines, 36.7% of the captured output, 100% of them +// arriving through that one function (1,197 from `error-response.ts`, 841 from +// `rest-server.ts`, 57 from `cause` chains). +// +// ⚠️ Those frames are NOT dead weight, and that is the whole reason this gate +// reads the way it does. At `logWithheldServerFault` the client is told nothing +// and the log is the operator's only copy of the driver text — which lives on +// `error.cause` and is printed only because an `Error` OBJECT reaches +// `console.error`. Four assertions across `rest-5xx-message-sanitization.test.ts` +// and `rest-expected-error-logging.test.ts` pin that by asserting the IDENTITY +// of the error that arrives (#5437 / #4886 / #5489), one of them carrying an +// explicit do-not-delete warning aimed at exactly this repair. +// +// So the ruled repair (decision batch #49 item 2) was a DECLARATION, not a +// quieter product: an `OS_REST_LOG` level seam in the `OS_REGISTRY_LOG` shape, +// with the shipped default unchanged. Which leaves two ways for it to rot, both +// silent, both restoring or destroying the population with every test green: +// +// 1. The declaration is deleted from the harness, or typo'd to a level the +// seam does not recognise. `log.ts` resolves an unrecognised value to the +// DEFAULT, so `OS_REST_LOG: 'quiet'` reads as a considered choice and +// changes nothing. This is the identical failure `check-registry-log- +// declared` documents for `OS_REGISTRY_LOG`. +// 2. The shipped DEFAULT is lowered to quieten a log. That is the repair the +// four pins exist to stop, and it is the one an author reaching for "the +// tests are too noisy" reaches for first. +// +// ── Why this gate reads library code where its sibling refuses to ─────────── +// +// `check-registry-log-declared` states, deliberately, that it asserts nothing +// about `packages/objectql`'s shipped `'info'` default: it reads harnesses. The +// difference here is that on #15484 the shipped default IS the ruled +// deliverable — 「⛔ The shipped default does not move. This card buys a +// declaration, not a quieter product」 — so a gate that read only the harness +// would enforce the half that was never in doubt and leave the half that was. +// Rule 4 below is that ruling, made mechanical. It is a floor, not a value +// choice: WHICH loud level ships stays the author's call. +// +// ── What is asserted ──────────────────────────────────────────────────────── +// +// 1. The seam is findable. The owning package is located by scanning source +// for the `OS_REST_LOG` environment read, never hardcoded, so moving +// `log.ts` cannot leave this gate guarding an empty spot. Zero owners, or +// more than one, is a MEASUREMENT FAILURE (exit 2) — never a pass. +// 2. The vocabulary is read from the seam's own `REST_LOG_LEVELS`, not copied +// here, so renaming a level cannot leave this gate enforcing a stale list. +// An unparseable declaration is exit 2. +// 3. ONE CONTRACT. `REST_LOG_LEVELS` must equal `@objectstack/objectql`'s +// `REGISTRY_LOG_LEVELS` as a set — the ruling asked for 「one logging +// contract, not a second ad-hoc env var」, and two seams that drift apart +// in vocabulary are two contracts wearing one name. +// 4. The shipped default, read from `REST_LOG_DEFAULT_LEVEL`, must be loud +// enough that BOTH sites still emit — `logWarn`'s threshold, not just +// `logError`'s. See the block above. +// 5. The owning package's package-root vitest config must carry `OS_REST_LOG` +// as a KEY inside an `env: { … }` block, with a value that is a string +// literal naming a recognised level. A docblock about the key never +// counts: the config's own rationale comment names the variable a dozen +// times. +// 6. For a config defining inline `projects`, the root-level `env` is INERT +// for project runs — the measured vitest 4.1.10 property +// `check-console-intercept-disarm` and `check-registry-log-declared` both +// record — so EVERY project's own `test` block must carry it too. +// 7. Any OTHER workspace package that declares `OS_REST_LOG` is held to the +// same value rules. It is not CONSCRIPTED into declaring one — see the +// narrowness note — but a declaration it does make must be real. +// +// ── A DECIDED narrowness, stated rather than discovered ───────────────────── +// +// The population is the seam's owner plus whoever opts in. It is deliberately +// NOT "every package whose tests route through this shim": measured at the time +// of writing, 21 workspace packages reference `@objectstack/rest` from their +// own test sources, `packages/spec` among them. Conscripting 21 packages into a +// declaration is a bigger change than the one that was ruled, and it would put +// this gate in the business of quietening suites it has never measured. The +// extension path is rule 7 — opt in by declaring, and the value is checked. +// +// ⚠️ Known duplication, not hidden: the brace-matching and env-block reading +// below are a second spelling of `check-registry-log-declared.mjs`'s. Extracting +// one shared reader is the right follow-up; it is not done here because that +// gate's self-test carries a battery floor this card has no mandate to move. +// +// Exit 0: the seam is declared and the default is loud. Exit 1: findings (each +// names the file, what is wrong, and the line to write). Exit 2: the gate could +// not measure — never reported as a pass. +// +// node scripts/check-rest-log-declared.mjs +// node scripts/check-rest-log-declared.mjs --self-test + +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { maskComments, maskCommentsAndLiterals } from './js-comment-mask.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; +import { workspacePackageDirs } from './check-console-intercept-disarm.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +/** The env seam this gate is about. */ +export const ENV_KEY = 'OS_REST_LOG'; +/** Where the sibling vocabulary lives, for the one-contract check (rule 3). */ +export const REGISTRY_LEVELS_SOURCE = 'packages/objectql/src/registry.ts'; + +const VITEST_CONFIG_NAMES = [ + 'vitest.config.ts', 'vitest.config.mts', 'vitest.config.cts', + 'vitest.config.js', 'vitest.config.mjs', 'vitest.config.cjs', +]; + +const SOURCE_EXT_RE = /\.(?:ts|tsx|mts|cts|js|mjs|cjs)$/; +const TEST_FILE_RE = /\.(?:test|spec)\.[a-z]+$/; +const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo', 'coverage', 'build']); + +/** The environment READ that marks the file owning the seam. */ +const ENV_READ_RE = new RegExp(String.raw`process\s*\)?\s*\??\.\s*env\s*\??\.\s*${ENV_KEY}\b`); + +const ENV_BLOCK_RE = /\benv\s*:\s*\{/g; +const KEY_RE = new RegExp(String.raw`\b${ENV_KEY}\s*:`); +const VALUE_RE = new RegExp(String.raw`\b${ENV_KEY}\s*:\s*(['"])([^'"]*)\1`); +const PROJECTS_RE = /\bprojects\s*:\s*\[/; +const TEST_BLOCK_RE = /\btest\s*:\s*\{/g; + +const REMEDY = ` env: { ${ENV_KEY}: '' },`; + +/** + * The levels that this seam recognises, read from its own source. + * @returns {{levels: string[], defaultLevel: string, file: string}} + */ +export function readSeam(file) { + const masked = maskComments(readFileSync(file, 'utf8')); + const lv = /REST_LOG_LEVELS[^=]*=\s*\[([^\]]*)\]/.exec(masked); + if (!lv) { + throw new Error( + `could not read REST_LOG_LEVELS out of ${file} — the declaration moved or changed shape. ` + + `Teach this reader the new one; do NOT hardcode the levels here, or renaming a level ` + + `leaves this gate enforcing a list nobody maintains.`, + ); + } + const levels = [...lv[1].matchAll(/['"]([a-z]+)['"]/g)].map((x) => x[1]); + if (levels.length === 0) throw new Error(`REST_LOG_LEVELS in ${file} parsed to ZERO levels`); + + const df = /REST_LOG_DEFAULT_LEVEL[^=]*=\s*['"]([a-z]+)['"]/.exec(masked); + if (!df) { + throw new Error( + `could not read REST_LOG_DEFAULT_LEVEL out of ${file}. The shipped default is the ruled ` + + `deliverable of #15484, so a default this gate cannot read is a measurement failure, ` + + `not a pass.`, + ); + } + return { levels, defaultLevel: df[1], file }; +} + +/** The sibling seam's vocabulary, for rule 3. */ +export function readRegistryLevels(root) { + const file = join(root, REGISTRY_LEVELS_SOURCE); + if (!existsSync(file)) { + throw new Error( + `${REGISTRY_LEVELS_SOURCE} is missing — this gate reads REGISTRY_LOG_LEVELS from it to hold ` + + `the two seams to ONE vocabulary. Point REGISTRY_LEVELS_SOURCE at the engine's new home.`, + ); + } + const m = /REGISTRY_LOG_LEVELS[^=]*=\s*\[([^\]]*)\]/.exec(maskComments(readFileSync(file, 'utf8'))); + if (!m) throw new Error(`could not read REGISTRY_LOG_LEVELS out of ${REGISTRY_LEVELS_SOURCE}`); + const levels = [...m[1].matchAll(/['"]([a-z]+)['"]/g)].map((x) => x[1]); + if (levels.length === 0) throw new Error(`REGISTRY_LOG_LEVELS parsed to ZERO levels`); + return levels; +} + +/** Every non-test source file under `dir`. */ +function sourceFiles(dir, out = []) { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return out; + } + for (const e of entries) { + if (e.isDirectory()) { + if (!SKIP_DIRS.has(e.name)) sourceFiles(join(dir, e.name), out); + } else if (SOURCE_EXT_RE.test(e.name) && !TEST_FILE_RE.test(e.name)) { + out.push(join(dir, e.name)); + } + } + return out; +} + +/** + * Locate the file that OWNS the seam by its environment read. + * @returns {string[]} absolute paths, one per owner found + */ +export function findSeamOwners(root) { + const owners = []; + for (const dir of workspacePackageDirs(root)) { + for (const file of sourceFiles(dir)) { + let raw; + try { + raw = readFileSync(file, 'utf8'); + } catch { + continue; + } + if (!raw.includes(ENV_KEY)) continue; + if (ENV_READ_RE.test(maskComments(raw))) owners.push(file); + } + } + return owners; +} + +/** Brace-matched ranges of every `env: { … }` block. */ +function envBlockRanges(masked) { + const ranges = []; + ENV_BLOCK_RE.lastIndex = 0; + let m; + while ((m = ENV_BLOCK_RE.exec(masked)) !== null) { + const open = m.index + m[0].length - 1; + let depth = 0; + for (let i = open; i < masked.length; i++) { + if (masked[i] === '{') depth += 1; + else if (masked[i] === '}') { + depth -= 1; + if (depth === 0) { ranges.push([open, i]); break; } + } + } + } + return ranges; +} + +/** Brace-matched ranges of every `test: { … }` block INSIDE the projects array. */ +function projectTestBlockRanges(masked) { + const start = PROJECTS_RE.exec(masked); + if (!start) return []; + const openBracket = masked.indexOf('[', start.index); + let depth = 0; + let end = -1; + for (let i = openBracket; i < masked.length; i++) { + if (masked[i] === '[') depth += 1; + else if (masked[i] === ']') { + depth -= 1; + if (depth === 0) { end = i; break; } + } + } + if (end < 0) return []; + const region = masked.slice(openBracket, end + 1); + const ranges = []; + TEST_BLOCK_RE.lastIndex = 0; + let m; + while ((m = TEST_BLOCK_RE.exec(region)) !== null) { + const open = openBracket + m.index + m[0].length - 1; + let d = 0; + for (let i = open; i <= end; i++) { + if (masked[i] === '{') d += 1; + else if (masked[i] === '}') { + d -= 1; + if (d === 0) { ranges.push([open, i]); break; } + } + } + } + return ranges; +} + +/** @returns {{declared: boolean, level: string|null, unquoted: boolean, recognised?: boolean}} */ +function declarationIn(code, comments, [from, to], levels) { + for (const [open, close] of envBlockRanges(code.slice(from, to + 1))) { + const a = from + open; + const b = from + close; + if (!KEY_RE.test(code.slice(a, b + 1))) continue; + const v = VALUE_RE.exec(comments.slice(a, b + 1)); + if (!v) return { declared: true, level: null, unquoted: true }; + const level = v[2].toLowerCase(); + return { declared: true, level, unquoted: false, recognised: levels.includes(level) }; + } + return { declared: false, level: null, unquoted: false }; +} + +function describeVerdict(verdict, where, levels) { + if (!verdict.declared) { + return `${where} declares no ${ENV_KEY} (a comment about it does not count)`; + } + if (verdict.unquoted) { + return `${where} sets ${ENV_KEY} to something this gate cannot read as a string literal`; + } + return ( + `${where} sets ${ENV_KEY}: '${verdict.level}', which is NOT one of the levels the seam ` + + `recognises (${levels.join(', ')}). log.ts resolves an unrecognised value to the SHIPPED ` + + `DEFAULT, silently — the declaration reads as a considered choice and declares nothing` + ); +} + +/** The verdict for one package's vitest config. */ +function checkConfig(root, dir, levels, findings, { required }) { + const name = rel(root, dir); + const configName = VITEST_CONFIG_NAMES.find((n) => existsSync(join(dir, n))); + if (!configName) { + if (required) { + findings.push( + `${name}: owns the ${ENV_KEY} seam and runs vitest with NO package-root vitest config, so ` + + `it can declare nothing. Add a vitest.config.ts whose test block carries:\n${REMEDY}`, + ); + } + return; + } + const raw = readFileSync(join(dir, configName), 'utf8'); + const code = maskCommentsAndLiterals(raw); + const comments = maskComments(raw); + const where = `${name}/${configName}`; + + if (!required && !KEY_RE.test(code)) return; // opt-in population: silent unless it opted in + + if (PROJECTS_RE.test(code)) { + const blocks = projectTestBlockRanges(code); + if (blocks.length === 0) { + findings.push( + `${where}: defines inline projects and no project test block could be read — a ROOT-level ` + + `env is INERT for project runs, so this config cannot be shown to declare anything. ` + + `Put\n${REMEDY}\ninside EVERY project's own test block.`, + ); + return; + } + const bad = blocks + .map((r) => declarationIn(code, comments, r, levels)) + .filter((v) => !v.declared || v.unquoted || !v.recognised); + if (bad.length > 0) { + findings.push( + `${where}: defines inline projects, and ${bad.length} of ${blocks.length} project test ` + + `block(s) do not declare a recognised ${ENV_KEY} level — first: ` + + `${describeVerdict(bad[0], 'that block', levels)}. A ROOT-level env is INERT for project ` + + `runs (the measured vitest 4.1.10 property check-console-intercept-disarm records). ` + + `Put\n${REMEDY}\ninside EVERY project's own test block.`, + ); + } + return; + } + + const verdict = declarationIn(code, comments, [0, code.length - 1], levels); + if (!verdict.declared || verdict.unquoted || !verdict.recognised) { + findings.push(`${describeVerdict(verdict, where, levels)}. Add to the test block:\n${REMEDY}`); + } +} + +/** @returns {{findings: string[], owner: string, levels: string[], defaultLevel: string}} */ +export function scan(root) { + const owners = findSeamOwners(root); + if (owners.length === 0) { + throw new Error( + `no source file reads process.env.${ENV_KEY} anywhere in the workspace. The seam this gate ` + + `guards is GONE or was renamed — which is not the same fact as "everything declares it". ` + + `A gate that finds nothing passes everything.`, + ); + } + if (owners.length > 1) { + throw new Error( + `${owners.length} source files read process.env.${ENV_KEY} (${owners.map((o) => rel(root, o)).join(', ')}). ` + + `The seam is meant to live in ONE shim; two readers means two spellings of the level and ` + + `this gate can no longer say which one a harness is declaring against.`, + ); + } + const seam = readSeam(owners[0]); + const findings = []; + + // Rule 3 — one contract. + const registry = readRegistryLevels(root); + const a = [...seam.levels].sort().join(','); + const b = [...registry].sort().join(','); + if (a !== b) { + findings.push( + `${rel(root, seam.file)}: REST_LOG_LEVELS (${seam.levels.join(', ')}) has drifted from ` + + `${REGISTRY_LEVELS_SOURCE}'s REGISTRY_LOG_LEVELS (${registry.join(', ')}). The ruling on ` + + `#15484 asked for ONE logging contract with two populations, not a second ad-hoc ` + + `environment variable. Bring the two vocabularies back together, in both sources.`, + ); + } + + // Rule 4 — the shipped default stays loud. + if (!seam.levels.includes(seam.defaultLevel)) { + findings.push( + `${rel(root, seam.file)}: REST_LOG_DEFAULT_LEVEL is '${seam.defaultLevel}', which is not one ` + + `of REST_LOG_LEVELS (${seam.levels.join(', ')}) — the shipped default resolves to nothing.`, + ); + } else if (QUIET_DEFAULTS.has(seam.defaultLevel)) { + findings.push( + `${rel(root, seam.file)}: REST_LOG_DEFAULT_LEVEL is '${seam.defaultLevel}', which stops at ` + + `least one of this shim's two sites from reporting at all for EVERY real caller. ` + + `⛔ #15484 bought a declaration, not a quieter product: 「the shipped default does not ` + + `move — a reported fault keeps printing the full Error (message, cause chain, frames)」. ` + + `Suppression is only ever what a HARNESS declares. If a suite is too noisy, declare a ` + + `level in that suite's vitest config; do not lower the default.`, + ); + } + + const ownerDir = packageDirOf(root, seam.file); + for (const dir of workspacePackageDirs(root)) { + checkConfig(root, dir, seam.levels, findings, { required: dir === ownerDir }); + } + return { findings, owner: rel(root, seam.file), levels: seam.levels, defaultLevel: seam.defaultLevel }; +} + +/** Levels at which at least one of the shim's two sites goes silent. */ +const QUIET_DEFAULTS = new Set(['silent', 'error']); + +function packageDirOf(root, file) { + for (const dir of workspacePackageDirs(root)) { + if (file.startsWith(dir + sep)) return dir; + } + return null; +} + +function rel(root, path) { + return path.startsWith(root + sep) ? path.slice(root.length + 1) : path; +} + +function main() { + let result; + try { + result = scan(REPO_ROOT); + } catch (error) { + console.error(`check-rest-log-declared: MEASUREMENT FAILED — ${error.message}`); + process.exit(2); + } + if (result.findings.length > 0) { + console.error( + `check-rest-log-declared: ${result.findings.length} finding(s):\n\n` + + `${result.findings.join('\n\n')}\n`, + ); + process.exit(1); + } + console.log( + `OK: ${result.owner} owns the ${ENV_KEY} seam (${result.levels.join('/')}), its shipped default ` + + `'${result.defaultLevel}' still reports a fault in full, and every declaring harness names a ` + + `recognised level.`, + ); +} + +// ── self-test ─────────────────────────────────────────────────────────────── +// +// Builds a throwaway workspace in $TMPDIR per case and pins the verdict +// DIRECTION of every rule in BOTH directions: a tree that satisfies the rule +// must pass, and the specific mutation the rule exists to catch must fail. A +// case that can only ever pass is not a case. + +const SELF_TEST_VERDICT = 'check-rest-log-declared self-test reached its verdict'; + +const REGISTRY_SRC = `export const REGISTRY_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];\n`; +const SEAM_SRC = (levels, def) => + `export const REST_LOG_LEVELS = [${levels.map((l) => `'${l}'`).join(', ')}] as const;\n` + + `export const REST_LOG_DEFAULT_LEVEL: RestLogLevel = '${def}';\n` + + `export function restLogLevel() {\n` + + ` const raw = String((globalThis as any).process?.env?.OS_REST_LOG ?? '').toLowerCase();\n` + + ` return raw;\n}\n`; +const GOOD_LEVELS = ['debug', 'info', 'warn', 'error', 'silent']; + +function buildWorkspace(caseDir, { seamLevels = GOOD_LEVELS, seamDefault = 'info', config, extra = {} } = {}) { + mkdirSync(caseDir, { recursive: true }); + writeFileSync(join(caseDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + + mkdirSync(join(caseDir, 'packages/objectql/src'), { recursive: true }); + writeFileSync(join(caseDir, 'packages/objectql/package.json'), JSON.stringify({ name: 'objectql' })); + writeFileSync(join(caseDir, 'packages/objectql/src/registry.ts'), REGISTRY_SRC); + + mkdirSync(join(caseDir, 'packages/rest/src'), { recursive: true }); + writeFileSync( + join(caseDir, 'packages/rest/package.json'), + JSON.stringify({ name: 'rest', scripts: { test: 'vitest run' } }), + ); + writeFileSync(join(caseDir, 'packages/rest/src/log.ts'), SEAM_SRC(seamLevels, seamDefault)); + if (config !== null) { + writeFileSync(join(caseDir, 'packages/rest/vitest.config.ts'), config ?? DECLARED_CONFIG); + } + for (const [p, body] of Object.entries(extra)) { + mkdirSync(dirname(join(caseDir, p)), { recursive: true }); + writeFileSync(join(caseDir, p), body); + } + return caseDir; +} + +const DECLARED_CONFIG = `export default { test: { env: { OS_REST_LOG: 'info' } } };\n`; +const UNDECLARED_CONFIG = `export default { test: { globals: true } };\n`; +const COMMENT_ONLY_CONFIG = `// OS_REST_LOG: 'silent' would go here\nexport default { test: { globals: true } };\n`; +const TYPO_CONFIG = `export default { test: { env: { OS_REST_LOG: 'quiet' } } };\n`; +const UNQUOTED_CONFIG = `export default { test: { env: { OS_REST_LOG: LEVEL } } };\n`; +const PROJECTS_BOTH = `export default { test: { projects: [` + + `{ test: { name: 'a', env: { OS_REST_LOG: 'info' } } },` + + `{ test: { name: 'b', env: { OS_REST_LOG: 'silent' } } }` + + `], env: { OS_REST_LOG: 'info' } } };\n`; +const PROJECTS_ONE_MISSING = `export default { test: { projects: [` + + `{ test: { name: 'a', env: { OS_REST_LOG: 'info' } } },` + + `{ test: { name: 'b', globals: true } }` + + `], env: { OS_REST_LOG: 'info' } } };\n`; + +const CASES = [ + ['a declared, recognised level passes', {}, (r) => r.findings.length === 0], + ['no declaration at all is a finding', { config: UNDECLARED_CONFIG }, + (r) => r.findings.some((f) => f.includes('declares no OS_REST_LOG'))], + ['a COMMENT naming the key is not a declaration', { config: COMMENT_ONLY_CONFIG }, + (r) => r.findings.some((f) => f.includes('declares no OS_REST_LOG'))], + ['an unrecognised level is a finding, not a pass', { config: TYPO_CONFIG }, + (r) => r.findings.some((f) => f.includes("'quiet'") && f.includes('NOT one of the levels'))], + ['a value this gate cannot read as a literal is a finding', { config: UNQUOTED_CONFIG }, + (r) => r.findings.some((f) => f.includes('cannot read as a string literal'))], + ['no vitest config at all is a finding for the owner', { config: null }, + (r) => r.findings.some((f) => f.includes('NO package-root vitest config'))], + ['every inline project declaring passes', { config: PROJECTS_BOTH }, (r) => r.findings.length === 0], + ['one inline project missing the declaration is a finding', { config: PROJECTS_ONE_MISSING }, + (r) => r.findings.some((f) => f.includes('project test block(s) do not declare'))], + ['a SILENT shipped default is a finding', { seamDefault: 'silent' }, + (r) => r.findings.some((f) => f.includes('REST_LOG_DEFAULT_LEVEL') && f.includes('EVERY real caller'))], + ['an ERROR shipped default is a finding — logWarn goes silent', { seamDefault: 'error' }, + (r) => r.findings.some((f) => f.includes('REST_LOG_DEFAULT_LEVEL') && f.includes('EVERY real caller'))], + ['a WARN shipped default passes — the floor is a floor, not a value choice', { seamDefault: 'warn' }, + (r) => r.findings.length === 0], + ['a default outside the vocabulary is a finding', { seamDefault: 'chatty' }, + (r) => r.findings.some((f) => f.includes('resolves to nothing'))], + ['a drifted vocabulary is a finding', { seamLevels: ['info', 'warn', 'error', 'silent'] }, + (r) => r.findings.some((f) => f.includes('drifted from'))], + ['another package that declares the key is checked too', + { extra: { + 'packages/other/package.json': JSON.stringify({ name: 'other', scripts: { test: 'vitest run' } }), + 'packages/other/vitest.config.ts': TYPO_CONFIG, + } }, + (r) => r.findings.some((f) => f.includes('packages/other') && f.includes("'quiet'"))], + ['another package that declares NOTHING is not conscripted', + { extra: { + 'packages/other/package.json': JSON.stringify({ name: 'other', scripts: { test: 'vitest run' } }), + 'packages/other/vitest.config.ts': UNDECLARED_CONFIG, + } }, + (r) => r.findings.length === 0], +]; + +const THROWING_CASES = [ + ['a vanished seam is a MEASUREMENT FAILURE, not a pass', + (dir) => writeFileSync(join(dir, 'packages/rest/src/log.ts'), 'export const nothing = 1;\n'), + /reads process\.env\.OS_REST_LOG anywhere/], + ['two seam readers is a MEASUREMENT FAILURE', + (dir) => { + mkdirSync(join(dir, 'packages/other/src'), { recursive: true }); + writeFileSync(join(dir, 'packages/other/package.json'), JSON.stringify({ name: 'other' })); + writeFileSync(join(dir, 'packages/other/src/log2.ts'), SEAM_SRC(GOOD_LEVELS, 'info')); + }, + /source files read process\.env\.OS_REST_LOG/], + ['an unreadable vocabulary is a MEASUREMENT FAILURE', + (dir) => writeFileSync( + join(dir, 'packages/rest/src/log.ts'), + 'export const REST_LOG_DEFAULT_LEVEL = \'info\';\n' + + 'export const x = process.env.OS_REST_LOG;\n', + ), + /could not read REST_LOG_LEVELS/], + ['an unreadable default is a MEASUREMENT FAILURE', + (dir) => writeFileSync( + join(dir, 'packages/rest/src/log.ts'), + 'export const REST_LOG_LEVELS = [\'debug\', \'info\', \'warn\', \'error\', \'silent\'];\n' + + 'export const x = process.env.OS_REST_LOG;\n', + ), + /could not read REST_LOG_DEFAULT_LEVEL/], +]; + +const SELF_TEST_FLOOR = CASES.length + THROWING_CASES.length; + +function selfTest() { + const tmp = mkdtempSync(join(tmpdir(), 'check-rest-log-declared-')); + let failures = 0; + let ran = 0; + try { + CASES.forEach(([label, opts, predicate], i) => { + const dir = buildWorkspace(join(tmp, `case-${i}`), opts); + let ok = false; + let detail = ''; + try { + const result = scan(dir); + ok = predicate(result); + if (!ok) detail = ` — findings: ${JSON.stringify(result.findings)}`; + } catch (error) { + detail = ` — threw: ${error.message}`; + } + ran += 1; + if (!ok) { failures += 1; console.error(` ✗ ${label}${detail}`); } + else console.log(` ✓ ${label}`); + }); + THROWING_CASES.forEach(([label, mutate, pattern], i) => { + const dir = buildWorkspace(join(tmp, `throw-${i}`), {}); + mutate(dir); + let ok = false; + let detail = ''; + try { + const result = scan(dir); + detail = ` — did NOT throw; findings: ${JSON.stringify(result.findings)}`; + } catch (error) { + ok = pattern.test(error.message); + if (!ok) detail = ` — threw the wrong message: ${error.message}`; + } + ran += 1; + if (!ok) { failures += 1; console.error(` ✗ ${label}${detail}`); } + else console.log(` ✓ ${label}`); + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + if (ran < SELF_TEST_FLOOR) { + console.error( + `check-rest-log-declared: self-test ran ${ran} case(s), below its own floor of ` + + `${SELF_TEST_FLOOR}. A shrinking battery is how a gate stops being tested.`, + ); + process.exit(2); + } + console.log(`${SELF_TEST_VERDICT}: ${ran} case(s), ${failures} failure(s).`); + if (failures > 0) process.exit(1); +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) selfTest(); + else main(); +}