From 0dfedebe01513e65e106930cfcacf4fc1d1ec56d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:55:51 +0000 Subject: [PATCH 1/2] fix(service-cluster): compile the test layer with tsc, repair the TS2322 it hid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/services/service-cluster` had no `typecheck` script at all, so no tsc program read the package: turbo/CI typecheck lanes skipped it silently (a zero-matching filter run exits 0), while tsup and vitest both type-STRIP. Its `tsconfig.json` does include the tests and always did — the program existed and was never invoked. That hid a TS2322 in `src/memory/memory.contract.test.ts`, the package's contract witness: a concise arrow body passed as a `PubSubHandler` returns `Array.prototype.push`'s `number` where the contract declares `void | Promise`, and the void-return relaxation does not forgive a UNION target. Fixed with a block body — the handler is side-effect-only by contract. The spec contract is untouched. Wired by the route #14062 settled for `packages/plugins/**`: a sibling `tsconfig.test.json` changing module semantics only (strictness inherited, untouched), named by a new `typecheck` script through `check:test-typecheck`. Measured 1/1 errors before (build semantics / new config — they agree, so no config-tier pile), 0/0 after, over a 410-file program covering all 7 test files. No `test-typecheck-debt.json` is added; its absence is the zero. The package's `DEBT` entry in `scripts/check-type-check-coverage.mjs` is deleted rather than lowered, which is the graduation that gate's own invariant requires. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../service-cluster-test-tsc-program.md | 58 ++++++++++++++ .../services/service-cluster/package.json | 5 +- .../src/memory/memory.contract.test.ts | 2 +- .../service-cluster/tsconfig.test.json | 78 +++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/check-type-check-coverage.mjs | 14 +++- 6 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 .changeset/service-cluster-test-tsc-program.md create mode 100644 packages/services/service-cluster/tsconfig.test.json diff --git a/.changeset/service-cluster-test-tsc-program.md b/.changeset/service-cluster-test-tsc-program.md new file mode 100644 index 0000000000..aa4815f502 --- /dev/null +++ b/.changeset/service-cluster-test-tsc-program.md @@ -0,0 +1,58 @@ +--- +"@objectstack/service-cluster": patch +--- + +fix(service-cluster): put the test layer in front of tsc, and repair the TS2322 it was hiding (#14181) + +`packages/services/service-cluster` had **no `typecheck` script at all** — its +scripts were `build` and `test` — so no tsc program anywhere read this package. +Turbo/CI typecheck lanes skipped it silently, because a zero-matching filter run +exits 0. `tsup` transpiles with esbuild and `vitest` runs through esbuild +type-**stripping**; neither type-checks. The package's own `tsconfig.json` does +include the tests and always did, so the program that would have read them +already existed and was simply never invoked. + +What that hid was in the worst possible file. `src/memory/memory.contract.test.ts` +is the package's **contract witness** — type conformance to the `IPubSub` / +`ILock` / `IKV` / `ICounter` contracts is the entire point of its existence — and +it did not compile: + +``` +src/memory/memory.contract.test.ts(26,46): error TS2322: + Type 'number' is not assignable to type 'void | Promise'. +``` + +`cluster.pubsub.subscribe('e', (m) => received.push(m.payload))` passes a concise +arrow body as a `PubSubHandler`, whose contract return type is +`void | Promise`. The body returns `Array.prototype.push`'s `number`, and +TypeScript's void-return assignability relaxation does **not** forgive it, +because the target is a UNION rather than a bare `void`. It is repaired with a +block body — the handler is side-effect-only by contract, and the returned length +was an accident of arrow syntax, never intent. The identical shape is what +`@objectstack/metadata` graduated on (20 of them, `(evt) => arr.push(evt)` in a +watcher slot). + +⛔ The spec contract is untouched: `PubSubHandler` returning `void | Promise` +is correct and deliberate (the union is what lets a driver `await` an async +handler). The defect was in the test, so the test is where it is fixed — no +consumer-side widening, no source signature change. + +Wired by the route the `packages/plugins/**` family settled on in #14062: a +sibling `tsconfig.test.json` that changes **module semantics only** (`esnext` / +`bundler` / `lib: ES2022`, matching how vitest actually executes these files) +with **strictness inherited and untouched**, named by a new `typecheck` script +through the shared `check:test-typecheck` gate. Measured before the repair: 1 +error under build semantics, 1 under the new config — the two readings agree, so +this package carried no config-tier pile. After: 0 and 0, across a 410-file +program covering all 7 of its `src/**/*.test.ts`. + +No `test-typecheck-debt.json` is added, and its **absence is the zero**: the gate +reads a missing ledger as `{ entries: {} }`, under which any error in any file +here is red immediately. The package's `DEBT` entry in +`scripts/check-type-check-coverage.mjs` (`errors: 1`) is deleted in this PR +rather than lowered — that is the graduation the ratchet's own invariant +requires, and it is why the error was fixed rather than ledgered. + +No runtime code changes: `src/**` (excluding tests) is byte-identical, so no +shipped behaviour moves. The `patch` level reflects the published `package.json` +gaining `typecheck` / `check:test-typecheck` scripts and a `tsx` devDependency. diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index f6f502c3bc..5c866c2a08 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -24,7 +24,9 @@ }, "scripts": { "build": "rm -rf dist && tsup && node ../../../scripts/check-dts-emitted.mjs", - "test": "vitest run" + "test": "vitest run", + "typecheck": "tsc --noEmit && pnpm check:test-typecheck", + "check:test-typecheck": "tsx ../../../scripts/check-test-typecheck.mts --self-test && tsx ../../../scripts/check-test-typecheck.mts --package packages/services/service-cluster --project tsconfig.test.json" }, "dependencies": { "@objectstack/core": "workspace:*", @@ -32,6 +34,7 @@ }, "devDependencies": { "@types/node": "^26.2.0", + "tsx": "^4.23.12", "typescript": "^6.0.3", "vitest": "^4.1.10" }, diff --git a/packages/services/service-cluster/src/memory/memory.contract.test.ts b/packages/services/service-cluster/src/memory/memory.contract.test.ts index ed0bc2b135..21d3e6bd4e 100644 --- a/packages/services/service-cluster/src/memory/memory.contract.test.ts +++ b/packages/services/service-cluster/src/memory/memory.contract.test.ts @@ -23,7 +23,7 @@ describe('defineCluster(memory) smoke', () => { // Round-trip through all four. const received: unknown[] = []; - cluster.pubsub.subscribe('e', (m) => received.push(m.payload)); + cluster.pubsub.subscribe('e', (m) => { received.push(m.payload); }); await cluster.pubsub.publish('e', 'hi'); expect(received).toEqual(['hi']); diff --git a/packages/services/service-cluster/tsconfig.test.json b/packages/services/service-cluster/tsconfig.test.json new file mode 100644 index 0000000000..fd901ccbd4 --- /dev/null +++ b/packages/services/service-cluster/tsconfig.test.json @@ -0,0 +1,78 @@ +// The TEST-layer type-check program (#14181 — the `packages/services/**` +// instance of the class #14062 settled for `packages/plugins/**`, itself +// adopting the mechanism #5286 set for `packages/spec`, #5449 generalised, +// #12542 carried to `packages/rest` and #13176 to `packages/plugins/ +// plugin-security`). `tsconfig.json` beside this one stays exactly as it is: it +// is the BUILD config. This sibling puts the test layer in front of tsc under +// the module semantics vitest really executes it with, and `package.json`'s +// `typecheck` script NAMES it (via `check:test-typecheck --project`), because a +// config no script invokes is exactly the phantom this whole change is about. +// +// ⚠️ WHAT WAS DIFFERENT HERE, and why this package was the worst case in the +// family rather than one more of it: `service-cluster` had NO `typecheck` +// script at all — its scripts were `build` and `test`. The other members hid +// their tests behind an `exclude` in a config some script still ran; this one +// ran no tsc anywhere. Its build config does NOT exclude tests and never did, +// so the program that would have read them already existed and simply was +// never invoked, while turbo/CI typecheck lanes skipped the package silently (a +// zero-matching filter run exits 0). `tsup` type-strips, `vitest` type-strips. +// +// What differs from the build config, and what deliberately does NOT: +// - MODULE SEMANTICS ONLY, plus `lib`. The tests are written and executed as +// ESM by vitest (esbuild/vite). Matching that is FIDELITY, not laxity: it is +// the same subtraction `packages/spec`, `packages/rest` and the +// `packages/plugins/**` family each made. +// - ⛔ STRICTNESS IS UNTOUCHED. `strict`, `noUnusedLocals`, +// `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, +// `rootDir`, `paths` and `types` are all INHERITED from `tsconfig.json` +// (and through it the root config), and none of them is re-declared here. +// ⚠️ A child that declared its own `paths` would REPLACE the parent map +// rather than merge into it, silently sending a source-resolved specifier +// back to `dist/` — a BUILD ARTIFACT — so this file declares none. +// Nothing here may loosen a type rule; if a test does not compile, that is +// the finding. +// - `lib: ["ES2022"]`, for the same reason `packages/rest` states: the root +// config's `lib` is ES2020 and vitest runs on a Node that has es2022 +// builtins, so the gap is reported as TS2550 about the CHECK. No `DOM`: +// nothing in this layer touches a browser global. +// +// MEASURED at 44ffa2103, workspace closure built first (`tsc --noEmit --pretty +// false --listFiles -p tsconfig.test.json`, and the same command without +// `--listFiles`): +// +// files in this program 410 +// own `src/**/*.test.ts` in it 7 +// errors under BUILD semantics 1 +// errors under THIS config 1 +// +// The two readings agree, so this package carried no config-tier pile at all — +// the single error is code-tier, and it is REPAIRED in the same PR rather than +// ledgered. It was a TS2322 at `src/memory/memory.contract.test.ts:26`: +// `(m) => received.push(m.payload)` passed as a `PubSubHandler`, whose contract +// return type is `void | Promise`. A concise arrow body returns +// `Array.prototype.push`'s `number`, and the void-return assignability +// relaxation does NOT forgive it because the target is a UNION rather than bare +// `void`. That is the same shape `@objectstack/metadata` graduated on (#14342), +// and the fix is a block body — the handler is side-effect-only by contract. +// Triage was explicit that this one is to be fixed, not ledgered. +// +// There is NO `test-typecheck-debt.json` beside this config, and its ABSENCE is +// the zero: `check:test-typecheck` reads a missing ledger as `{ entries: {} }`, +// under which ANY error in ANY file here is red immediately, with no entry to be +// added to. That is strictly stronger than a ledger holding nothing, and it is +// the same call `plugin-webhooks` and `plugin-security` (#13176) each recorded +// for themselves. If this package ever acquires residue that cannot be fixed in +// the PR that causes it, THAT is when a ledger and a `gen:test-typecheck-debt` +// script are owed — and adding one is maintainer-only (#5286), exactly as the +// gate says when it refuses. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2022"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ffe91fa2a..bd64d536b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2374,6 +2374,9 @@ importers: '@types/node': specifier: ^26.2.0 version: 26.2.0 + tsx: + specifier: ^4.23.12 + version: 4.23.12 typescript: specifier: ^6.0.3 version: 6.0.3 diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 7d079a4a1b..c92048b3ae 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -663,6 +663,16 @@ const ROOT_PROGRAM_COUPLED_SCRIPT = 'scripts/check-test-typecheck.mts'; // already itemised its own tiers with confidence: a tier split read off an // unrepaired config is a guess about what is UNDER it, and the only honest way // to size the code tier is to fix the config and look. +// +// `@objectstack/service-cluster` GRADUATED from this ledger (#14181; entry: 1 +// raw, repaired to 0). Its single TS2322 was the very shape the paragraph above +// itemises for `metadata` -- `(m) => received.push(m.payload)` in a slot typed +// `void | Promise` -- caught here in the package's own CONTRACT witness. +// It is worth a line because this package reached the ledger by a different road +// than the rest: it had NO `typecheck` script at all, so its build config never +// ran even though that config DOES include the tests. Repaired by the #5286 +// route -- a `tsconfig.test.json` over the test layer, named by a new `typecheck` +// script -- so the entry is deleted rather than lowered. const DEBT = { '@objectstack/cloud-connection': { errors: 13, @@ -690,10 +700,6 @@ const DEBT = { + 'by acquiring a second file, then 5 -> 3 by graduating the first -- so re-read what the pile is ' + 'made of before sizing it, never just the number.', }, - '@objectstack/service-cluster': { - errors: 1, - note: 'code-tier 1 (TS2322).', - }, '@objectstack/service-knowledge': { errors: 10, note: 'code-tier 3 (TS2339/TS2352/TS2493); config-tier 3 (TS2835); noise 4 (TS7006). Re-measured 10 at ' From eb41bcdad2257b1c4712abb3f73efb92b88051fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:27:42 +0000 Subject: [PATCH 2/2] fix(service-cluster): re-baseline the type-source-resolution registry for the onboarded test program Onboarding a `tsconfig.test.json` moves this package's tsc PROGRAM SET, which `check:type-source-resolution` judges per program. `service-cluster` had NO `typecheck` script before, so it ran zero counted programs; both deps the gate now reports are reached only through the program this change added. Taken on the onboarding limb the registry's own doc-block opens, on its three stated terms. Provenance measured four ways by varying only what `typecheck` names: absent with no script, absent naming `tsconfig.json` alone, PRESENT naming `tsconfig.test.json`. The build program carries no dist-resolved workspace type import at all, so the exposure is only reachable through the onboarded program rather than merely first seen there. `--list` before 57/78 packages, 118 programs, 288 pairs; after 58/78, 119, 290. +1 package, +1 program, +2 pairs -- this entry and nothing else. `paths` was measured rather than argued and is the wrong tool here: redirecting the two deps to source takes the test layer from 0 errors to 435, all TS6059 and all in `packages/spec/src` and `packages/core/src` -- another package's diagnostics billed to one that cannot pay them down (PR #12570, #8021). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- scripts/check-type-source-resolution.mjs | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/scripts/check-type-source-resolution.mjs b/scripts/check-type-source-resolution.mjs index 4a81aa97ab..d9f182bb4c 100644 --- a/scripts/check-type-source-resolution.mjs +++ b/scripts/check-type-source-resolution.mjs @@ -586,6 +586,49 @@ const KNOWN_DIST_RESOLVED_TYPE_IMPORTS = { '@objectstack/lint', '@objectstack/mcp', '@objectstack/platform-objects', '@objectstack/plugin-auth', '@objectstack/spec', '@objectstack/types', ], + // #14181 re-baseline (the onboarding limb above): a NEW entry, reached ONLY + // through `tsconfig.test.json` -- a program this card ADDED. This is the + // limb's cleanest case rather than a borderline one: `service-cluster` had NO + // `typecheck` script AT ALL before (its scripts were `build` and `test`), so + // it ran ZERO counted programs and there is no pre-existing program for a dep + // to be laundered through. Both deps here are annotated `via + // tsconfig.test.json` by this gate's own failure text. + // + // Provenance measured four ways on one checkout, by varying only what the + // `typecheck` script NAMES (`--list`, totals as printed): + // + // no `typecheck` script (origin/main) absent 118 programs / 288 pairs + // names `tsconfig.json` only absent 118 programs / 288 pairs + // names `tsconfig.test.json` only PRESENT 119 programs / 290 pairs + // names both (this card) PRESENT 119 programs / 290 pairs + // + // Row 2 is the load-bearing one: the BUILD program carries no dist-resolved + // workspace type import at all, so the exposure is not merely first SEEN + // through the onboarded program, it is only REACHABLE through it. (The two + // programs put the same files in -- this package's `tsconfig.json` has never + // excluded tests -- so module semantics, NodeNext vs bundler, is the only + // axis that differs.) + // + // Numbers, `--list` before/after on the same checkout (before at 44ffa2103, + // after with this card applied): + // + // before 57 of 78 packages, 118 programs, 288 pairs, 21 clean + // after 58 of 78 packages, 119 programs, 290 pairs, 20 clean + // + // so +1 package, +1 program, +2 pairs -- this entry and nothing else. + // + // Why the entry and not `paths`, which is what this gate's failure text asks + // for: MEASURED both ways on the same checkout, and `paths` is decisively the + // wrong tool here. Redirecting these two deps to source takes this package's + // test layer from 0 errors to 435, ALL of them TS6059 (`not under rootDir`) + // and every one of them in ANOTHER package's source -- `packages/spec/src/**` + // and `packages/core/src/**` -- billed to a package that cannot pay them down. + // That is the PR #12570 finding (+5 TS6133 for `rest`) and the #8021 one (247 + // TS6059) reproduced at a much larger scale, on a package whose entire point + // in this card was to reach ZERO test-layer errors. Note the direction: the + // #5286 route it took makes its OWN test files compile clean, and `paths` + // would immediately re-bury that result under other packages' diagnostics. + '@objectstack/service-cluster': ['@objectstack/core', '@objectstack/spec'], // #14386 re-baseline (the onboarding limb above): a NEW entry, reached ONLY // through `tsconfig.typecheck.json` -- a program that card ADDED (this // package's `typecheck` was a bare `tsc --noEmit` before it, with no sibling