diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e005a75bae..41dd9c1bf7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3846,6 +3846,19 @@ jobs: - name: Declared REST fault-log level run: node scripts/check-rest-log-declared.mjs --self-test && node scripts/check-rest-log-declared.mjs + # The pairing half of the one above (#17865, ruling batch #128 item 4). + # The `packages/rest` harness now runs at `OS_REST_LOG: 'silent'`, which + # takes those 2,095 frame lines to ZERO — and that is safe only while + # every test file that OBSERVES the fault log declares its own loud level. + # Half of that population announces itself when it breaks: 15 files / 28 + # test cases go RED the moment the suite is silenced. The other half does + # not. Eight files assert that an expected 4xx logs NOTHING, and a + # silenced shim makes those pass with every 4xx logged loudly — a phantom + # check reached by a legitimate-looking config line. This gate names an + # undeclared observer instead. Self-test first. + - name: Declared REST fault-log level — observers + run: node scripts/check-rest-log-spy-declared.mjs --self-test && node scripts/check-rest-log-spy-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/package.json b/package.json index 500a34a64f..3b96736b15 100644 --- a/package.json +++ b/package.json @@ -161,6 +161,7 @@ "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:rest-log-spy-declared": "node scripts/check-rest-log-spy-declared.mjs --self-test && node scripts/check-rest-log-spy-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/src/analytics-16019-driver-declared-fault.test.ts b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts index 4f64bf14bb..88abee4d06 100644 --- a/packages/rest/src/analytics-16019-driver-declared-fault.test.ts +++ b/packages/rest/src/analytics-16019-driver-declared-fault.test.ts @@ -59,13 +59,21 @@ * case stays GREEN, which is precisely why it could not stand in for this one. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import type { Logger } from '@objectstack/spec/contracts'; import { AnalyticsService } from '@objectstack/service-analytics'; import { SqlDriver } from '@objectstack/driver-sql'; import { INTERNAL_ERROR_MESSAGE, declaresServerFault, looksLikeInternalErrorLeak } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness (the shape `analytics-dataset-dimension-gate.test.ts` uses) ────── function mockServer() { diff --git a/packages/rest/src/analytics-dataset-dimension-gate.test.ts b/packages/rest/src/analytics-dataset-dimension-gate.test.ts index 62d305fa35..9f74815a4b 100644 --- a/packages/rest/src/analytics-dataset-dimension-gate.test.ts +++ b/packages/rest/src/analytics-dataset-dimension-gate.test.ts @@ -56,12 +56,20 @@ * sources: mutating the service without rebuilding it proves nothing here. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import type { Logger } from '@objectstack/spec/contracts'; import { AnalyticsService } from '@objectstack/service-analytics'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness (the shape `analytics-filter-refusal-envelope.test.ts` uses) ────── function mockServer() { diff --git a/packages/rest/src/analytics-dataset-where-gate.test.ts b/packages/rest/src/analytics-dataset-where-gate.test.ts index e0e82b7d7b..dd84172034 100644 --- a/packages/rest/src/analytics-dataset-where-gate.test.ts +++ b/packages/rest/src/analytics-dataset-where-gate.test.ts @@ -49,11 +49,19 @@ * must be falsifiable on its own. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import type { Logger } from '@objectstack/spec/contracts'; import { AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness (the shape the two sibling analytics rest tests use) ───────────── function mockServer() { diff --git a/packages/rest/src/analytics-fault-user-message.test.ts b/packages/rest/src/analytics-fault-user-message.test.ts index c32de90258..01e75fa1ac 100644 --- a/packages/rest/src/analytics-fault-user-message.test.ts +++ b/packages/rest/src/analytics-fault-user-message.test.ts @@ -99,11 +99,19 @@ * or strategy. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; import { handleRouteError } from './error-response.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness (the shape the sibling analytics envelope tests use) ────────────── function mockServer() { diff --git a/packages/rest/src/analytics-filter-refusal-envelope.test.ts b/packages/rest/src/analytics-filter-refusal-envelope.test.ts index d8009ac1c5..52808e27f4 100644 --- a/packages/rest/src/analytics-filter-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-filter-refusal-envelope.test.ts @@ -45,12 +45,20 @@ * be re-labelled with a code of its own choosing. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import type { Logger } from '@objectstack/spec/contracts'; import { AnalyticsService } from '@objectstack/service-analytics'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness ────────────────────────────────────────────────────────────────── function mockServer() { diff --git a/packages/rest/src/analytics-read-scope-refusal-envelope.test.ts b/packages/rest/src/analytics-read-scope-refusal-envelope.test.ts index 6058105940..be2d92184b 100644 --- a/packages/rest/src/analytics-read-scope-refusal-envelope.test.ts +++ b/packages/rest/src/analytics-read-scope-refusal-envelope.test.ts @@ -71,12 +71,20 @@ * threw. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import type { Logger } from '@objectstack/spec/contracts'; import { AnalyticsService } from '@objectstack/service-analytics'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // ── harness (the shape the sibling analytics envelope tests use) ────────────── function mockServer() { diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 1186a3cfa0..ddc4357afb 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -88,7 +88,7 @@ * outside vitest (it needs git history) and is recorded on the card. */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; @@ -103,6 +103,14 @@ import type { RouteHandler } from '@objectstack/spec/contracts'; import { registerPackageRoutes } from './package-routes.js'; import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const PKGS = '/api/v1/packages'; const HERE = dirname(fileURLToPath(import.meta.url)); const SOURCE = readFileSync(resolve(HERE, 'rest-server.ts'), 'utf8'); diff --git a/packages/rest/src/external-write-forbidden-envelope.test.ts b/packages/rest/src/external-write-forbidden-envelope.test.ts index 9c9e486682..520bbb2b75 100644 --- a/packages/rest/src/external-write-forbidden-envelope.test.ts +++ b/packages/rest/src/external-write-forbidden-envelope.test.ts @@ -64,12 +64,20 @@ // had this test lie about what it measures. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { ObjectQL } from '@objectstack/objectql'; import type { IDataDriver } from '@objectstack/spec/contracts'; import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_COLLECTION = '/api/v1/data/:object'; const DATA_ITEM = '/api/v1/data/:object/:id'; diff --git a/packages/rest/src/meta-app-nav-servability-gate.test.ts b/packages/rest/src/meta-app-nav-servability-gate.test.ts index bc1a304563..ca5e945330 100644 --- a/packages/rest/src/meta-app-nav-servability-gate.test.ts +++ b/packages/rest/src/meta-app-nav-servability-gate.test.ts @@ -18,13 +18,21 @@ * and must fail here. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; // Explicit `.js` extension: this package's tsconfig resolves NodeNext, so the // extensionless spelling its older test files use is a TS2835 — 67 of them are // frozen in the TEST_DEBT ledger, which only ever shrinks. A new file must not // add the 68th. import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const ANON_API = { api: { requireAuth: false } }; function createMockServer() { diff --git a/packages/rest/src/rest-5xx-message-sanitization.test.ts b/packages/rest/src/rest-5xx-message-sanitization.test.ts index 25945dc756..02141de03e 100644 --- a/packages/rest/src/rest-5xx-message-sanitization.test.ts +++ b/packages/rest/src/rest-5xx-message-sanitization.test.ts @@ -58,12 +58,20 @@ // keep the status the producer declared, keep the machine-readable `code`, drop // the prose. The status-preservation assertions below are what pin that. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META_ITEM = '/api/v1/meta/:type/:name'; const OBJ_BATCH = '/api/v1/data/:object/batch'; const BATCH_OBJECT = 'showcase_account'; diff --git a/packages/rest/src/rest-5xx-status-passthrough.test.ts b/packages/rest/src/rest-5xx-status-passthrough.test.ts index a6779264bc..1b30f5496e 100644 --- a/packages/rest/src/rest-5xx-status-passthrough.test.ts +++ b/packages/rest/src/rest-5xx-status-passthrough.test.ts @@ -62,10 +62,18 @@ // above the passthrough are untouched. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { mapDataError, RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_LIST = '/api/v1/data/:object'; // --------------------------------------------------------------------------- diff --git a/packages/rest/src/rest-data-door-code-prefix.test.ts b/packages/rest/src/rest-data-door-code-prefix.test.ts index 6caee424b9..0dbd84de96 100644 --- a/packages/rest/src/rest-data-door-code-prefix.test.ts +++ b/packages/rest/src/rest-data-door-code-prefix.test.ts @@ -112,11 +112,19 @@ * anchored strip answers the well-formed idiom byte-identically. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; // `.js` extension deliberately: this package resolves `nodenext`, so an // extensionless relative import is a `tsc` error (TS2835). import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const ITEM = '/api/v1/data/:object/:id'; const COLLECTION = '/api/v1/data/:object'; const SHARES = '/api/v1/data/:object/:id/shares'; diff --git a/packages/rest/src/rest-declared-refusal-relay.test.ts b/packages/rest/src/rest-declared-refusal-relay.test.ts index b5cfe1af32..c9e1122a6e 100644 --- a/packages/rest/src/rest-declared-refusal-relay.test.ts +++ b/packages/rest/src/rest-declared-refusal-relay.test.ts @@ -51,12 +51,20 @@ * pass by the two strings coinciding. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META = '/api/v1/meta'; /** Prose a producer authored FOR the caller. Names nothing tenant-sensitive. */ diff --git a/packages/rest/src/rest-endpoint-surfaces-served-only.test.ts b/packages/rest/src/rest-endpoint-surfaces-served-only.test.ts index 089283a93b..b9540380e7 100644 --- a/packages/rest/src/rest-endpoint-surfaces-served-only.test.ts +++ b/packages/rest/src/rest-endpoint-surfaces-served-only.test.ts @@ -46,11 +46,19 @@ // guard that changed colour with the fix would be pinning the fix rather than // the invariant. Both predictions were confirmed by running it (see the PR). -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { MetadataManager } from '@objectstack/metadata'; import { MemoryLoader } from '@objectstack/metadata'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // --------------------------------------------------------------------------- // The three declarations, each standing for one way a route can exist // --------------------------------------------------------------------------- diff --git a/packages/rest/src/rest-expected-error-logging.test.ts b/packages/rest/src/rest-expected-error-logging.test.ts index f923766850..c543d07074 100644 --- a/packages/rest/src/rest-expected-error-logging.test.ts +++ b/packages/rest/src/rest-expected-error-logging.test.ts @@ -33,9 +33,17 @@ // bug is never silent — is the same one, and is now carried by the status band // rather than by the absence of a `code`. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META_ITEM = '/api/v1/meta/:type/:name'; const DATA_LIST = '/api/v1/data/:object'; diff --git a/packages/rest/src/rest-hook-refusal-code-parity.test.ts b/packages/rest/src/rest-hook-refusal-code-parity.test.ts index 3ad54a1dcb..ad3cb7ece8 100644 --- a/packages/rest/src/rest-hook-refusal-code-parity.test.ts +++ b/packages/rest/src/rest-hook-refusal-code-parity.test.ts @@ -74,12 +74,20 @@ // prediction is reported rather than fitted to the measurement. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; // `.js` extension deliberately: this package resolves `nodenext`, so an // extensionless relative import is a `tsc` error (TS2835). import { mapDataError, RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_COLLECTION = '/api/v1/data/:object'; const DATA_ITEM = '/api/v1/data/:object/:id'; diff --git a/packages/rest/src/rest-hook-refusal-message-parity.test.ts b/packages/rest/src/rest-hook-refusal-message-parity.test.ts index 9bc5417e5f..c8d1ddfe20 100644 --- a/packages/rest/src/rest-hook-refusal-message-parity.test.ts +++ b/packages/rest/src/rest-hook-refusal-message-parity.test.ts @@ -94,13 +94,21 @@ // re-reading both files from the repository root rather than trusting the trap. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; // `.js` extension deliberately: this package resolves `nodenext`, so an // extensionless relative import is a `tsc` error (TS2835). import { mapDataError, handleRouteError } from './error-response.js'; import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_COLLECTION = '/api/v1/data/:object'; const DATA_ITEM = '/api/v1/data/:object/:id'; diff --git a/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts b/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts index 195a044dc4..e16e7f2c57 100644 --- a/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts +++ b/packages/rest/src/rest-hook-refusal-status-passthrough.test.ts @@ -85,10 +85,18 @@ // 4 red. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { mapDataError, RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_ITEM = '/api/v1/data/:object/:id'; const DATA_COLLECTION = '/api/v1/data/:object'; diff --git a/packages/rest/src/rest-log-declared-level-seam.test.ts b/packages/rest/src/rest-log-declared-level-seam.test.ts index 922fb8f3da..317510cad0 100644 --- a/packages/rest/src/rest-log-declared-level-seam.test.ts +++ b/packages/rest/src/rest-log-declared-level-seam.test.ts @@ -28,7 +28,7 @@ // 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 { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from 'vitest'; import { logError, logWarn, @@ -38,6 +38,14 @@ import { type RestLogLevel, } from './log.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + let errorSpy: ReturnType; let warnSpy: ReturnType; let saved: string | undefined; diff --git a/packages/rest/src/rest-meta-outage-vs-miss.test.ts b/packages/rest/src/rest-meta-outage-vs-miss.test.ts index c0ba44c133..fa5804756b 100644 --- a/packages/rest/src/rest-meta-outage-vs-miss.test.ts +++ b/packages/rest/src/rest-meta-outage-vs-miss.test.ts @@ -17,10 +17,18 @@ // engine. The producer's own end is pinned in // `@objectstack/metadata-protocol`'s `protocol.metadata-store-outage.test.ts`. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META_ITEM = '/api/v1/meta/:type/:name'; function createMockServer() { diff --git a/packages/rest/src/rest-sandbox-declared-status.test.ts b/packages/rest/src/rest-sandbox-declared-status.test.ts index 0ca397a53a..c7c7982a80 100644 --- a/packages/rest/src/rest-sandbox-declared-status.test.ts +++ b/packages/rest/src/rest-sandbox-declared-status.test.ts @@ -42,10 +42,18 @@ // rather than here so a wrong prediction cannot be rewritten to fit. // --------------------------------------------------------------------------- -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { mapDataError, RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_ITEM = '/api/v1/data/:object/:id'; // --------------------------------------------------------------------------- diff --git a/packages/rest/src/rest-server-repeated-filter-param.test.ts b/packages/rest/src/rest-server-repeated-filter-param.test.ts index 197cf821e6..88c5a92cb3 100644 --- a/packages/rest/src/rest-server-repeated-filter-param.test.ts +++ b/packages/rest/src/rest-server-repeated-filter-param.test.ts @@ -47,7 +47,7 @@ * duplicates serves one of two intents a caller actually expressed. */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeAll, afterAll } from 'vitest'; // `.js` on purpose — NodeNext resolution requires the extension, and this // package's TEST_DEBT ceiling has no margin for another TS2835 (#7248). import { ObjectQL } from '@objectstack/objectql'; @@ -60,6 +60,14 @@ import { repeatedFilterParamMessage, } from './query-multiplicity.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA = '/api/v1/data'; function mockServer() { diff --git a/packages/rest/src/rest-thrown-code-vocabulary.test.ts b/packages/rest/src/rest-thrown-code-vocabulary.test.ts index dd619492ab..997afe04e9 100644 --- a/packages/rest/src/rest-thrown-code-vocabulary.test.ts +++ b/packages/rest/src/rest-thrown-code-vocabulary.test.ts @@ -48,7 +48,7 @@ // redden, so it can only be defended by being written down. Confirmed by // running it; see the PR. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; // `.js` extension deliberately: this package resolves `nodenext`, so an @@ -57,6 +57,14 @@ import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; // must not add to a shrink-only ratchet. import { mapDataError, sendThrownError, sendDeclaredFault } from './error-response.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // --------------------------------------------------------------------------- // Vehicles // --------------------------------------------------------------------------- diff --git a/packages/rest/src/rest-unclassified-fault-status.test.ts b/packages/rest/src/rest-unclassified-fault-status.test.ts index a46e4c50e6..08d94b1040 100644 --- a/packages/rest/src/rest-unclassified-fault-status.test.ts +++ b/packages/rest/src/rest-unclassified-fault-status.test.ts @@ -62,10 +62,18 @@ // overreach, a fix that starts promoting client mistakes to server faults. // Confirmed by running it; see the PR. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { mapDataError, RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META_ITEM = '/api/v1/meta/:type/:name'; function createMockServer() { diff --git a/packages/rest/src/rest-unknown-object-heuristic.test.ts b/packages/rest/src/rest-unknown-object-heuristic.test.ts index ddfa6b6b4d..8dd61ea3ee 100644 --- a/packages/rest/src/rest-unknown-object-heuristic.test.ts +++ b/packages/rest/src/rest-unknown-object-heuristic.test.ts @@ -53,11 +53,19 @@ // // Measured after predicting it; the run is quoted in the PR. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { mapDataError, RestServer } from './rest-server'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const META_ITEM = '/api/v1/meta/:type/:name'; const OBJ_BATCH = '/api/v1/data/:object/batch'; diff --git a/packages/rest/src/rest-user-facing-refusal-marking.test.ts b/packages/rest/src/rest-user-facing-refusal-marking.test.ts index cfd6828658..4c063e7b6a 100644 --- a/packages/rest/src/rest-user-facing-refusal-marking.test.ts +++ b/packages/rest/src/rest-user-facing-refusal-marking.test.ts @@ -16,11 +16,19 @@ // in `error-response.ts`): whatever envelope classification chooses, a declared // `userMessage` rides it, and it never moves the status or the `code`. -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { mapDataError, RestServer } from './rest-server.js'; import { handleRouteError } from './error-response.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const USER_TEXT = '该记录已进入月末结账期,暂不能修改;请联系财务主管解锁。'; /** The authoring shape the ruling targets: a hook guard's deliberate 403. */ diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 00ea430f4a..15ab901d7e 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi, beforeAll, afterAll } from 'vitest'; import { RouteManager } from './route-manager'; import { RestServer, mapDataError } from './rest-server'; import { createRestApiPlugin } from './rest-api-plugin'; @@ -9,6 +9,14 @@ import { loadXlsxWorkbook } from './xlsx-test-loader.js'; import { httpRequestForRoute } from './http-request-test-builder.js'; import { httpResponseTestDouble } from './http-response-test-builder.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + // --------------------------------------------------------------------------- // Mocks & Helpers // --------------------------------------------------------------------------- diff --git a/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts index c2c4ea9df2..6b595fb885 100644 --- a/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts +++ b/packages/rest/src/single-kernel-isolated-api-key-matrix.test.ts @@ -43,10 +43,18 @@ * derives, so the real `computeExecCtx` → `resolveAuthzContext` chain runs. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { hashApiKey, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE } from '@objectstack/core'; import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_COLLECTION = '/api/v1/data/:object'; const OBJECT = 'sys_business_unit'; diff --git a/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts b/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts index 31b87830bb..996d4f1f9e 100644 --- a/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts +++ b/packages/rest/src/single-kernel-isolated-session-org-claim-matrix.test.ts @@ -75,10 +75,18 @@ * behaviour, which is the direction that fails silently. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { hashApiKey, ANONYMOUS_DENY_STATUS, ANONYMOUS_DENY_CODE } from '@objectstack/core'; import { RestServer } from './rest-server.js'; +// [#17865] This file observes the REST fault log, so it declares the level it +// asserts against instead of inheriting the suite's quiet one. 'info' is the +// SHIPPED default — what a real caller gets. Paired by +// scripts/check-rest-log-spy-declared.mjs: an observer that declares nothing +// is a finding by name. +beforeAll(() => { vi.stubEnv('OS_REST_LOG', 'info'); }); +afterAll(() => { vi.unstubAllEnvs(); }); + const DATA_COLLECTION = '/api/v1/data/:object'; const OBJECT = 'sys_business_unit'; diff --git a/packages/rest/vitest.config.ts b/packages/rest/vitest.config.ts index 84331960cd..cbb4bbc058 100644 --- a/packages/rest/vitest.config.ts +++ b/packages/rest/vitest.config.ts @@ -28,11 +28,13 @@ 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. - // #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' }, + // #15484 / #17865: `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 this HARNESS runs quiet while the + // shipped default stays `'info'`, and for the two things the quiet + // level does not work without. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'silent' }, // 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). @@ -48,11 +50,13 @@ 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. - // #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' }, + // #15484 / #17865: `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 this HARNESS runs quiet while the + // shipped default stays `'info'`, and for the two things the quiet + // level does not work without. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'silent' }, // 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). @@ -79,34 +83,39 @@ 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. - // #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. + // #15484 / #17865: `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`. ⛔ This is the HARNESS's level, not + // the product's: the SHIPPED default is still `'info'` and still gate-pinned, + // and nothing here changes what a real caller gets. // - // ⚠️ 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: + // ⚠️ MEASURED on this suite, both before and after. At `'info'` a green run + // captured 5,705 lines of which 2,095 (36.7%) were indented `at ` stack + // frames, 100% of them arriving through `logError`; at `'silent'` that + // population is ZERO. But the quiet level alone is NOT a volume tidy — 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 + // * assertions that read the fault through a `vi.spyOn(console, 'error')` + // mock go RED. They are the "the operator still gets the words" half of + // the contract, and they never printed any of the volume in the first + // place. + // * the OTHER half asserts 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' }, + // ⇒ The quiet level is therefore paired, in the SAME delivery, with two + // things it does not work without (decision batch #128 item 4, #17865): + // every test file that observes the fault log declares its own loud level + // (`vi.stubEnv('OS_REST_LOG', …)` in that file's own setup), and + // `scripts/check-rest-log-spy-declared.mjs` makes an undeclared observer a + // finding by name — so a future test cannot assert silence into a silenced + // suite. ⛔ Do not raise this value back to a loud one to "fix" a red test: + // the file that went red is the one missing its own declaration. + env: { OS_REGISTRY_LOG: 'warn', OS_REST_LOG: 'silent' }, globals: true, environment: 'node', }, diff --git a/scripts/check-rest-log-spy-declared.mjs b/scripts/check-rest-log-spy-declared.mjs new file mode 100644 index 0000000000..9dc19d8001 --- /dev/null +++ b/scripts/check-rest-log-spy-declared.mjs @@ -0,0 +1,558 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// check-rest-log-spy-declared — a test file that OBSERVES the REST fault log +// must DECLARE the level it observes under, in its own source, at a level loud +// enough for the shim to speak. +// +// ── The defect this keeps closed (#17865, ruling batch #128 item 4) ───────── +// +// `packages/rest`'s harness now runs at `OS_REST_LOG: 'silent'` +// (`packages/rest/vitest.config.ts`). That removes a measured 2,095 indented +// `at ` frame lines — 36.7% of a captured green run, 100% of them arriving +// through `logError` — and it is safe ONLY while the tests that assert about +// the fault log declare their own level. The opt-down and this gate are one +// delivery: 「a pairing gate … a test file that spies on `console.error` / the +// fault logger without declaring `OS_REST_LOG` is a finding by name. It lands +// in the same PR as 1; ⛔ 1 does not merge without 3.」 +// +// ⚠️ The half that makes this a gate and not a lint rule is the NEGATIVE +// assertion. Measured on the tree this landed against: 15 test files / 28 test +// cases go RED the moment the suite is silenced — those announce themselves. +// The other direction is silent. Eight files assert that an EXPECTED 4xx logs +// NOTHING (`expect(unhandledLogs()).toHaveLength(0)` and siblings); under a +// silenced shim those stay GREEN while logging every expected 4xx loudly. That +// is a phantom check arrived at by a legitimate-looking config line — the exact +// shape that spent this suite's `[Registry]` control on #15484, and the reason +// option C ("opt down, change nothing else") was measured and refused. +// +// So the rule is not "assert loudly". It is: an observer OWNS its level. +// +// ── What is asserted ──────────────────────────────────────────────────────── +// +// 1. The seam is findable, and there is exactly one of it. Delegated to +// `check-rest-log-declared.mjs`'s own reader rather than re-implemented, +// so the two gates cannot disagree about which file owns the seam or which +// levels exist. Zero owners, or more than one, is a MEASUREMENT FAILURE +// (exit 2) — never a pass. +// 2. An OBSERVER is a test file in the owning package that reads the fault +// channel: a `vi`/`jest` `spyOn(console, 'error'|'warn')`, a direct +// `console.error =` / `console.warn =` mock install, or an import of the +// seam module itself. `logWarn` routes to `console.warn` and the same +// level gates it, so a warn spy is an observer exactly as an error spy is. +// 3. Every observer declares `OS_REST_LOG` in CODE. A comment naming the key +// never counts — this file's own header names it a dozen times. +// 4. Every level literal an observer pairs with the key is one the seam +// recognises. `log.ts` resolves an unrecognised value to the shipped +// DEFAULT, silently, so `'quiet'` reads as a considered choice and +// declares nothing. Same failure `check-rest-log-declared` documents for +// the harness config. +// 5. At least one of those pairings is loud enough that BOTH of the shim's +// sites still emit — `logWarn`'s threshold, not just `logError`'s. A file +// whose only declaration is `'silent'` has re-created the vacuum the +// opt-down would have created for it; a file whose only declaration is +// `'error'` has done it to `logWarn`. This is a FLOOR, not a value choice: +// a file may declare quiet levels as well (the ladder cases in +// `rest-log-declared-level-seam.test.ts` do), it just may not declare ONLY +// quiet ones. A declaration this gate cannot read as a literal at all — +// `process.env.OS_REST_LOG = someVariable` — satisfies neither 4 nor 5: +// the key being mentioned is not the same fact as a level being declared. +// 6. A test file that is NOT an observer is not conscripted into declaring +// anything — but a declaration it does make is still held to rule 4. +// +// ── Why ZERO OBSERVERS is a failure and not a clean bill ──────────────────── +// +// This gate recognises the spellings it knows. If the suite migrates to a +// helper this detector has never seen, the observer count silently becomes 0 +// and every file passes — a gate that finds nothing passes everything, which is +// how the `[Registry]` control went to zero and read as "capture failed". So an +// owning package with test files but ZERO observers is a MEASUREMENT FAILURE. +// Extend the detector (and add a self-test case) rather than routing around it. +// +// ⚠️ Known duplication, stated rather than hidden: the workspace walk and the +// test-file predicate below are a third spelling of the one in +// `check-registry-log-declared.mjs` / `check-rest-log-declared.mjs`. What is +// NOT duplicated is the part that matters — seam location and the level +// vocabulary are IMPORTED from the sibling gate, so a level renamed in +// `log.ts` moves both gates at once. +// +// Exit 0: every observer owns its level. 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-spy-declared.mjs +// node scripts/check-rest-log-spy-declared.mjs --self-test + +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { maskComments } from './js-comment-mask.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; +import { workspacePackageDirs } from './check-console-intercept-disarm.mjs'; +import { ENV_KEY, findSeamOwners, readSeam } from './check-rest-log-declared.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '..'); + +/* ── The declared path population (#13519 / check-declared-population-live) ─── + * These literals ARE what this gate reads, and they are deliberately as wide as + * its sibling's: locating the seam means asking EVERY workspace package whether + * one of its non-test sources reads the environment key, because a SECOND + * reader appearing anywhere is an exit-2 measurement failure here. Only the + * second half — the test files examined — is narrow, and its directory is + * DERIVED from wherever the seam turned out to live, so it cannot be spelled as + * a literal without re-introducing the hardcoded package path this gate refuses + * to carry. Spelled WITH a separator: a bare single-segment literal builds no + * hint at all. The self-test holds these against the walk, so a moved read + * reddens here rather than turning this gate silently unnameable by any + * dispatch brief. */ +export const WORKSPACE_FILE = 'pnpm-workspace.yaml'; +export const ROOT_DIR_WATCH_HINTS = ['packages/**', 'apps/**', 'examples/**']; + +const TEST_FILE_RE = /\.(?:test|spec)\.[a-z]+$/; +const SKIP_DIRS = new Set(['node_modules', 'dist', '.turbo', 'coverage', 'build']); + +/** + * Levels at which at least one of the shim's two sites goes silent — the same + * floor `check-rest-log-declared.mjs` holds the SHIPPED default to, applied + * here to what a test file declares for itself. + */ +export const QUIET_LEVELS = new Set(['silent', 'error']); + +/** A `spyOn(console, 'error'|'warn')` in any of the spellings in use. */ +const SPY_RE = /\b(?:vi|jest)\s*\.\s*spyOn\s*\(\s*(?:globalThis\s*\.\s*)?console\s*(?:,\s*|\[\s*)['"`](error|warn)['"`]/; +/** A mock installed by assignment: `console.error = …`, `console['warn'] = …`. */ +const ASSIGN_RE = /\bconsole\s*(?:\.\s*(?:error|warn)|\[\s*['"`](?:error|warn)['"`]\s*\])\s*=[^=]/; + +/** + * Every pairing of the key with a string literal, in the three shapes a test + * file can write: `vi.stubEnv('OS_REST_LOG', 'info')`, `OS_REST_LOG: 'info'` + * inside an env block, and `process.env.OS_REST_LOG = 'info'`. + */ +const PAIRING_RE = new RegExp(String.raw`${ENV_KEY}['"\`]?\s*(?:,|:|=)\s*['"\`]([^'"\`]*)['"\`]`, 'g'); +const KEY_RE = new RegExp(String.raw`\b${ENV_KEY}\b`); + +const REMEDY = ` beforeAll(() => { vi.stubEnv('${ENV_KEY}', ''); });\n` + + ' afterAll(() => { vi.unstubAllEnvs(); });'; + +/** Every test file under `dir`, recursively. */ +export function testFiles(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)) testFiles(join(dir, e.name), out); + } else if (TEST_FILE_RE.test(e.name)) { + out.push(join(dir, e.name)); + } + } + return out; +} + +/** + * The import specifiers by which a file in `fromDir` would name the seam + * module. Derived from where the seam actually lives, so moving `log.ts` + * moves this with it. + * @returns {string[]} + */ +export function seamSpecifiers(fromDir, seamFile) { + const bare = relative(fromDir, seamFile).replace(/\.[cm]?[jt]sx?$/, ''); + const posix = bare.split(sep).join('/'); + const rooted = posix.startsWith('.') ? posix : `./${posix}`; + return [rooted, `${rooted}.js`, `${rooted}.ts`, `${rooted}.mjs`]; +} + +/** + * Does this file read the fault channel? + * @returns {{observer: boolean, why: string|null}} + */ +export function observes(code, fromDir, seamFile) { + if (SPY_RE.test(code)) return { observer: true, why: 'spies on console.error/warn' }; + if (ASSIGN_RE.test(code)) return { observer: true, why: 'installs a console.error/warn mock' }; + for (const spec of seamSpecifiers(fromDir, seamFile)) { + const re = new RegExp(String.raw`from\s*['"\`]${spec.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`); + if (re.test(code)) return { observer: true, why: `imports the fault shim (${spec})` }; + } + return { observer: false, why: null }; +} + +/** + * The levels a file pairs with the key, in source order. + * @returns {string[]} + */ +export function declaredLevels(code) { + PAIRING_RE.lastIndex = 0; + return [...code.matchAll(PAIRING_RE)].map((m) => m[1].toLowerCase()); +} + +/** @returns {{findings: string[], owner: string, observers: string[], examined: number}} */ +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 whose ` + + `observers this gate pairs is GONE or was renamed — which is not the same fact as ` + + `"every observer 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(', ')}). Two readers means two spellings of the ` + + `level, and this gate can no longer say which one a test file is declaring against.`, + ); + } + const seamFile = owners[0]; + const { levels } = readSeam(seamFile); + const ownerDir = packageDirOf(root, seamFile); + if (!ownerDir) { + throw new Error(`${rel(root, seamFile)} is not inside any workspace package — cannot scope the scan.`); + } + + const files = testFiles(ownerDir); + if (files.length === 0) { + throw new Error( + `${rel(root, ownerDir)} owns the ${ENV_KEY} seam and contains NO test files. This gate exists ` + + `to pair observers with declarations; with nothing to examine it can only report a ` + + `vacuous pass.`, + ); + } + + const findings = []; + const observers = []; + for (const file of files) { + const code = maskComments(readFileSync(file, 'utf8')); + const where = rel(root, file); + const verdict = observes(code, dirname(file), seamFile); + const pairings = declaredLevels(code); + const unrecognised = pairings.filter((l) => !levels.includes(l)); + + if (!verdict.observer) { + // Rule 6 — not conscripted, but a declaration it makes must be real. + if (KEY_RE.test(code) && unrecognised.length > 0) { + findings.push( + `${where}: declares ${ENV_KEY}: '${unrecognised[0]}', 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.`, + ); + } + continue; + } + observers.push(where); + + if (!KEY_RE.test(code)) { + findings.push( + `${where}: ${verdict.why} but declares no ${ENV_KEY} (a comment about it does not count). ` + + `The suite this file runs in declares a QUIET level, so what this file observes is not ` + + `what a real caller gets — and an assertion that NOTHING was logged would pass for the ` + + `wrong reason. Declare the level this file asserts against:\n${REMEDY}`, + ); + continue; + } + if (unrecognised.length > 0) { + findings.push( + `${where}: ${verdict.why} and pairs ${ENV_KEY} with '${unrecognised[0]}', 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.`, + ); + continue; + } + if (pairings.length === 0) { + findings.push( + `${where}: ${verdict.why} and mentions ${ENV_KEY}, but this gate cannot read a LEVEL out of ` + + `it — the key is paired with something that is not a string literal. The key being ` + + `mentioned is not the same fact as a level being declared. Add a readable one:\n` + + `${REMEDY}`, + ); + continue; + } + if (pairings.every((l) => QUIET_LEVELS.has(l))) { + findings.push( + `${where}: ${verdict.why} and declares only quiet level(s) (${[...new Set(pairings)].join(', ')}), ` + + `at which at least one of the shim's two sites stops emitting. Every assertion this file ` + + `makes about the fault log is then true of a shim that never spoke — including any ` + + `assertion that NOTHING was logged, which is the vacuum this gate exists to refuse. ` + + `Declaring a quiet level as WELL is fine (a level-ladder test needs it); declaring only ` + + `quiet ones is not. Add a loud pairing:\n${REMEDY}`, + ); + } + } + + if (observers.length === 0) { + throw new Error( + `${rel(root, ownerDir)} has ${files.length} test file(s) and NOT ONE of them reads the fault ` + + `channel by a spelling this gate recognises. That is far more likely to mean the detector ` + + `has gone stale than that the package stopped testing its own fault log. Teach the ` + + `detector the new spelling and add a --self-test case for it; do not leave it reporting a ` + + `pass over a population it can no longer see.`, + ); + } + + return { findings, owner: rel(root, seamFile), observers, examined: files.length }; +} + +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-spy-declared: MEASUREMENT FAILED — ${error.message}`); + process.exit(2); + } + if (result.findings.length > 0) { + console.error( + `check-rest-log-spy-declared: ${result.findings.length} finding(s):\n\n` + + `${result.findings.join('\n\n')}\n`, + ); + process.exit(1); + } + console.log( + `OK: ${result.observers.length} of ${result.examined} test file(s) beside ${result.owner} ` + + `observe the fault log, and every one of them declares its own ${ENV_KEY} 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-spy-declared self-test reached its verdict'; + +const REGISTRY_SRC = `export const REGISTRY_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];\n`; +const SEAM_SRC = + `export const REST_LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'silent'] as const;\n` + + `export const REST_LOG_DEFAULT_LEVEL: RestLogLevel = 'info';\n` + + `export function restLogLevel() {\n` + + ` return String((globalThis as any).process?.env?.OS_REST_LOG ?? '').toLowerCase();\n}\n`; + +const DECLARE = (level) => `beforeAll(() => { vi.stubEnv('OS_REST_LOG', '${level}'); });\n`; +const SPY = `const spy = vi.spyOn(console, 'error').mockImplementation(() => {});\n`; +const WARN_SPY = `const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});\n`; +const JEST_SPY = `const spy = jest.spyOn(console, 'error');\n`; +const ASSIGN = `console.error = vi.fn();\n`; +const IMPORTS_SEAM = `import { logError } from './log.js';\n`; +const PLAIN = `it('adds', () => { expect(1 + 1).toBe(2); });\n`; + +/** One case's `packages/rest/src` test files, as `{ name: body }`. */ +function buildWorkspace(caseDir, tests) { + 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' })); + writeFileSync(join(caseDir, 'packages/rest/src/log.ts'), SEAM_SRC); + for (const [name, body] of Object.entries(tests)) { + writeFileSync(join(caseDir, 'packages/rest/src', name), body); + } + return caseDir; +} + +/** Every case carries one always-conforming observer so rule "zero observers" never fires by accident. */ +const ANCHOR = { 'anchor.test.ts': DECLARE('info') + SPY }; + +const CASES = [ + ['an observer that declares a loud level passes', + { 'a.test.ts': DECLARE('info') + SPY }, (r) => r.findings.length === 0], + ['an observer that declares nothing is a finding', + { ...ANCHOR, 'a.test.ts': SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('declares no OS_REST_LOG'))], + ['a COMMENT naming the key is not a declaration', + { ...ANCHOR, 'a.test.ts': `// vi.stubEnv('OS_REST_LOG', 'info') would go here\n` + SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('declares no OS_REST_LOG'))], + ['an unrecognised level is a finding, not a pass', + { ...ANCHOR, 'a.test.ts': DECLARE('quiet') + SPY }, + (r) => r.findings.some((f) => f.includes("'quiet'") && f.includes('NOT one of the levels'))], + ['an observer declaring ONLY silent is a finding — the vacuum this gate refuses', + { ...ANCHOR, 'a.test.ts': DECLARE('silent') + SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('only quiet level'))], + ['an observer declaring ONLY error is a finding — logWarn goes silent', + { ...ANCHOR, 'a.test.ts': DECLARE('error') + SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('only quiet level'))], + ['a level LADDER — quiet declared as well as loud — passes', + { 'a.test.ts': DECLARE('info') + DECLARE('silent') + SPY }, (r) => r.findings.length === 0], + ['the key paired with a VARIABLE is not a declared level', + { ...ANCHOR, 'a.test.ts': `process.env.OS_REST_LOG = level;\n` + SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('cannot read a LEVEL'))], + ['a console.warn spy is an observer too — logWarn routes there', + { ...ANCHOR, 'a.test.ts': WARN_SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('declares no OS_REST_LOG'))], + ['a jest.spyOn spelling is an observer too', + { ...ANCHOR, 'a.test.ts': JEST_SPY }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('declares no OS_REST_LOG'))], + ['a mock installed by ASSIGNMENT is an observer too', + { ...ANCHOR, 'a.test.ts': ASSIGN }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('declares no OS_REST_LOG'))], + ['importing the seam module makes a file an observer', + { ...ANCHOR, 'a.test.ts': IMPORTS_SEAM + PLAIN }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes('imports the fault shim'))], + ['a NON-observer test file is not conscripted', + { ...ANCHOR, 'a.test.ts': PLAIN }, (r) => r.findings.length === 0], + ['a NON-observer that declares a TYPO is still a finding', + { ...ANCHOR, 'a.test.ts': DECLARE('quiet') + PLAIN }, + (r) => r.findings.some((f) => f.includes('a.test.ts') && f.includes("'quiet'"))], + ['an env-block declaration inside a test file is readable too', + { 'a.test.ts': `const env = { OS_REST_LOG: 'info' };\n` + SPY }, (r) => r.findings.length === 0], +]; + +const THROWING_CASES = [ + ['ZERO observers is a MEASUREMENT FAILURE, not a clean bill', + { 'a.test.ts': PLAIN }, /NOT ONE of them reads the fault channel/], + ['a package with NO test files is a MEASUREMENT FAILURE', + {}, /contains NO test files/], +]; + +/** + * Cases taken against the REAL tree rather than a fixture: the declared + * population has to reach the tree and cover the walk, and the scan has to see + * a non-empty population in it. A wrong hint runs perfectly green in production + * and shows up only as a dev who was never told this gate reads their surface. + */ +const LIVE_TREE_CASES = 5; + +const SELF_TEST_FLOOR = CASES.length + THROWING_CASES.length + 1 + LIVE_TREE_CASES; + +function liveTreeCases(record) { + record( + existsSync(join(REPO_ROOT, WORKSPACE_FILE)), + `the declared population reaches the tree: ${WORKSPACE_FILE}`, + '', + ); + record( + ROOT_DIR_WATCH_HINTS.every((h) => h.includes('/')), + 'every ROOT_DIR_WATCH_HINTS entry is spelled with a separator — a bare segment builds no hint', + ` — ${JSON.stringify(ROOT_DIR_WATCH_HINTS)}`, + ); + const roots = ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*+$/, '')); + record( + roots.every((r) => existsSync(join(REPO_ROOT, r))), + 'every declared hint root exists in the tree', + ` — ${JSON.stringify(roots)}`, + ); + let uncovered = ['']; + try { + uncovered = workspacePackageDirs(REPO_ROOT) + .map((d) => rel(REPO_ROOT, d)) + .filter((r) => !roots.some((root) => r === root || r.startsWith(`${root}/`))); + } catch { /* reported by the assertion below */ } + record( + uncovered.length === 0, + 'the hints COVER the workspace walk this gate performs — a narrower declaration under-names it', + uncovered.length ? ` — uncovered: ${uncovered.slice(0, 3).join(', ')}` : '', + ); + // Anti-vacuity over the real tree: a scan that selects nothing is the one + // failure this gate cannot report as a finding — it would print a perfect + // green over a detector that matches nothing. + let live = null; + let why = ''; + try { + live = scan(REPO_ROOT); + } catch (error) { + why = ` — threw: ${error.message}`; + } + record( + live !== null && live.observers.length > 0 && live.examined > live.observers.length, + 'the real tree yields a NON-EMPTY observer population that is a strict subset of its test files', + live ? ` — ${live.observers.length} observer(s) of ${live.examined} test file(s)` : why, + ); +} + +function selfTest() { + const tmp = mkdtempSync(join(tmpdir(), 'check-rest-log-spy-declared-')); + let failures = 0; + let ran = 0; + const record = (ok, label, detail) => { + ran += 1; + if (!ok) { failures += 1; console.error(` ✗ ${label}${detail}`); } + else console.log(` ✓ ${label}`); + }; + try { + CASES.forEach(([label, tests, predicate], i) => { + const dir = buildWorkspace(join(tmp, `case-${i}`), tests); + 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}`; + } + record(ok, label, detail); + }); + THROWING_CASES.forEach(([label, tests, pattern], i) => { + const dir = buildWorkspace(join(tmp, `throw-${i}`), tests); + 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}`; + } + record(ok, label, detail); + }); + + // A vanished seam is the sibling gate's reader failing, reached through + // this one — pinned here because this gate DELEGATES that read and a + // delegation that stopped throwing would be invisible from the sibling. + { + const label = 'a vanished seam is a MEASUREMENT FAILURE, reached through the delegated reader'; + const dir = buildWorkspace(join(tmp, 'throw-seam'), ANCHOR); + writeFileSync(join(dir, 'packages/rest/src/log.ts'), 'export const nothing = 1;\n'); + let ok = false; + let detail = ''; + try { + scan(dir); + detail = ' — did NOT throw'; + } catch (error) { + ok = /reads process\.env\.OS_REST_LOG anywhere/.test(error.message); + if (!ok) detail = ` — threw the wrong message: ${error.message}`; + } + record(ok, label, detail); + } + + liveTreeCases(record); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + if (ran < SELF_TEST_FLOOR) { + console.error( + `check-rest-log-spy-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(); +}