Skip to content

Commit 8dcc0f5

Browse files
os-zhuangclaude
andauthored
fix(spec,runtime): 槽查找 any 规则补上类型实参形态并把作用域扩到全部 packages(#4251 第一批) (#4321)
* fix(spec,runtime): catch the getService<any> type-argument form and widen the lookup guard to all packages (#4251) The #4127/#4214 rule matched ': any' and 'as any' on a lookup result but not the type-argument form getService<any>(...) — the shape the codebase actually used — and its files glob stopped at packages/runtime while the composition roots held the majority of lookups. Both gaps close here: - eslint.config.mjs: third selector for the type-argument form (typeArguments.params.0 = TSAnyKeyword), scope widened to packages/**, http.server added to UNCONTRACTED_SLOTS (three providers, no contract). The 40 not-yet-swept files are grandfathered in SLOT_LOOKUP_UNSWEPT — a visible ratchet enumerated at 180 sites by running the rule with the list emptied; batches remove entries, never add. - The three in-scope runtime src sites are typed. Doing so surfaced the batch's first yield: both addDatasource branches (DefaultDatasourcePlugin parity branch, DriverPlugin.start) probed a method no metadata service implements, so they had never run on any boot — deleted rather than typed against a phantom shape. The inert DriverPluginOptions are tracked in #4320; registerInMemory('datasource', ...) is the actual visibility path. - Contracts declare the evidenced members those sites read, both optional: IDataEngine.getDefaultDriverName/getDriverByName (ObjectQL's driver registry, re-registered as driver.<name> services for os migrate and serve storage detection) and IMetadataService.registerInMemory (MetadataManager's boot-time seeding primitive, #3827). - Runtime test lookups typed as IDataEngine; the five http.server test lookups ride the slot exemption. Verified: pnpm lint clean; spec check:generated all 8 up to date; tests green — spec 7137/277, objectql 1345/85, runtime 954/67, metadata 281/13, plugin-webhooks 25/3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz * docs(kernel): document the evidenced contract members added for #4251 The IDataEngine and IMetadataService contract references enumerate the interface surface member by member, so the two additions (driver registry lookups; registerInMemory) were stale-by-omission — flagged by the docs drift check on #4321. Both interface listings and the per-member sections now match packages/spec/src/contracts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz * feat(runtime,cli)!: retire the inert DriverPluginOptions (#4320) new DriverPlugin(driver, { datasourceName, registerAsDefault }) never did what it promised: both options configured the start() datasource block that probed metadata.addDatasource — a method no metadata service implements — so they were dead weight on every boot since inception. Routing to a named auxiliary driver never came from the option: it keys off the DRIVER name (init registers driver.<name>, ObjectQL's discovery loop adopts it, the engine's lifecycle resolution looks the name up — engine.ts LIFECYCLE_DATASOURCE), which is why serve's telemetry split worked all along despite the option doing nothing. - DriverPlugin constructor narrowed to (driver, driverName?); the options interface (module-local, never exported from the package root) is gone. - serve.ts drops the options argument; the telemetry wiring comment now states the driver-name mechanism explicitly. - Major changeset for @objectstack/runtime carries the FROM -> TO migration; behavior is unchanged by construction (the code the options configured never ran). Verified: full turbo build green (71 packages); runtime 954/67 and cli 628/64 tests pass; pnpm lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2266a6 commit 8dcc0f5

12 files changed

Lines changed: 287 additions & 81 deletions
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/runtime": major
3+
"@objectstack/cli": patch
4+
---
5+
6+
feat(runtime)!: retire the inert `DriverPluginOptions``DriverPlugin` takes `(driver, driverName?)` (#4320)
7+
8+
`new DriverPlugin(driver, { datasourceName, registerAsDefault })` never did
9+
what it promised: both options configured a datasource-registration block in
10+
`start()` gated on `metadata.addDatasource`, a method **no metadata service
11+
implements** — so the block early-returned on every boot since inception and
12+
the options were dead weight (found while typing service lookups for #4251).
13+
14+
**Migration** — delete the options argument; nothing changes at runtime
15+
because nothing ever happened:
16+
17+
- FROM `new DriverPlugin(driver, { datasourceName: 'x', registerAsDefault: false })`
18+
TO `new DriverPlugin(driver)`
19+
- FROM `new DriverPlugin(driver, 'name', options)` TO `new DriverPlugin(driver, 'name')`
20+
- The string second argument (`new DriverPlugin(driver, 'memory')`) is unchanged.
21+
22+
If you passed `datasourceName` expecting routing to a named auxiliary driver:
23+
that routing never came from the option. It keys off the **driver name**
24+
`DriverPlugin.init()` registers `driver.<name>`, ObjectQL's discovery loop
25+
adopts it, and the engine's lifecycle/datasource resolution looks the name up
26+
(see the telemetry provision in `os serve` for the pattern: stamp
27+
`driver.name`, register the plugin, done). For Setup → Datasources visibility,
28+
declare the datasource through `DatasourceConnectionService` /
29+
`registerInMemory('datasource', …)` (ADR-0062).
30+
31+
The `DriverPluginOptions` interface was module-local (never exported from the
32+
package root), so the only public break is the constructor's second/third
33+
argument shape.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(spec,runtime): the service-lookup `any` guard now sees the type-argument form, and its scope stops at nothing under `packages/` (#4251)
7+
8+
The #4127/#4214 rule banned `: any` and `as any` on a service-lookup result but
9+
not `getService<any>('data')` — the form the codebase actually used (80 sites,
10+
zero matches), erasing the slot contract identically. And the rule's `files`
11+
covered only `packages/runtime`, leaving the composition roots (rest,
12+
plugins/*, services/*) that hold most lookups unlinted. Both gaps closed: a
13+
third AST selector catches the type-argument form, the scope is now all of
14+
`packages/`, and the 40 not-yet-swept files are grandfathered in a visible,
15+
shrinking ratchet list (`SLOT_LOOKUP_UNSWEPT`) — enumerated at 180 sites by
16+
running the widened rule with the list emptied. `http.server` joins
17+
`UNCONTRACTED_SLOTS` (three providers, no written contract).
18+
19+
Typing the three in-scope runtime sites surfaced its first yield: both
20+
`addDatasource` datasource-registration branches (DefaultDatasourcePlugin,
21+
DriverPlugin) probed a method **no metadata service implements**, so they had
22+
never run on any boot — deleted rather than typed against a phantom shape. The
23+
inert `DriverPluginOptions` they configured are tracked in #4320.
24+
`registerInMemory('datasource', …)` is the actual visibility path (#3827).
25+
26+
Contract members declared from evidence, both optional: `IDataEngine` gains
27+
`getDefaultDriverName?()` / `getDriverByName?()` (ObjectQL's driver registry —
28+
the surface `os migrate` and serve's storage detection reach through
29+
`driver.<name>` services), `IMetadataService` gains `registerInMemory?()`
30+
(MetadataManager's boot-time seeding primitive). Callers that supplied `<any>`
31+
to these lookups should pass the slot's contract type instead — or nothing:
32+
an unmapped slot deliberately resolves to `unknown`, not `any`.

content/docs/kernel/contracts/data-engine.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export interface IDataEngine {
5252

5353
// Raw Command Escape Hatch (optional)
5454
execute?(command: any, options?: Record<string, any>): Promise<any>;
55+
56+
// Driver Registry (optional — engines that own named drivers)
57+
getDefaultDriverName?(): string | undefined;
58+
getDriverByName?(name: string): IDataDriver | undefined;
5559
}
5660
```
5761

@@ -365,6 +369,21 @@ const result = await engine.execute?.(
365369
);
366370
```
367371

372+
### getDefaultDriverName / getDriverByName (Driver Registry)
373+
374+
Look up the engine's registered drivers. Only engines that own a named-driver
375+
registry implement these (ObjectQL does; test fakes and remote/virtual engines
376+
need not) — always probe with `?.`:
377+
378+
```typescript
379+
const driverName = engine.getDefaultDriverName?.();
380+
const driver = driverName ? engine.getDriverByName?.(driverName) : undefined;
381+
```
382+
383+
This is the surface the runtime uses to re-register the default driver as a
384+
`driver.<name>` kernel service, which is where `os migrate` and serve's storage
385+
detection locate drivers.
386+
368387
---
369388

370389
## Error Codes

content/docs/kernel/contracts/metadata-service.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ definition for a type.
3030
export interface IMetadataService {
3131
// Core CRUD (by type + name)
3232
register(type: string, name: string, data: unknown): Promise<void>;
33+
registerInMemory?(type: string, name: string, data: unknown): void;
3334
get(type: string, name: string): Promise<unknown | undefined>;
3435
list(type: string): Promise<unknown[]>;
3536
unregister(type: string, name: string): Promise<void>;
@@ -166,6 +167,26 @@ await metadataService.unregister('object', 'project');
166167
To inspect what would break before removing an item, call `getDependents('object', 'project')` — it returns the items that reference this one.
167168
</Callout>
168169

170+
### registerInMemory
171+
172+
Optional. Seeds an item into the in-memory registry **only** — never persisted
173+
to the DB store, never announced to `watch` subscribers. This is the boot-time
174+
path for source-control-owned artefacts that must be *listable* without
175+
creating DB drift — e.g. code-defined datasources (`origin: 'code'`), which is
176+
how the default datasource appears in Setup → Datasources:
177+
178+
```typescript
179+
metadataService.registerInMemory?.('datasource', 'default', {
180+
name: 'default',
181+
label: 'Default',
182+
driver: 'sqlite',
183+
origin: 'code',
184+
});
185+
```
186+
187+
Callers that mutate metadata mid-run want `register`, which persists and
188+
announces.
189+
169190
---
170191

171192
## Bulk Operations

eslint.config.mjs

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,16 +66,82 @@ const SLOT_LOOKUPS = ['resolveService', 'getService', 'getRequestKernelService']
6666
// eslint-disable comments on purpose: exceptions belong in one reviewable
6767
// place, not sprinkled through the code. Deleting a name from this list is how
6868
// the exemption ends once that contract gets written.
69-
const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager'].join('|');
69+
//
70+
// Entries are spliced into a regex, so escape metacharacters (`http\\.server`).
71+
// `http.server` is served by three providers (plugin-hono-server, runtime's
72+
// config.server path, qa's node-plugin) and no IHttpServer contract exists;
73+
// callers read only `getPort()`.
74+
const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager', 'http\\.server'].join('|');
7075

7176
const SLOT_LOOKUP_ANY_MESSAGE =
72-
'Do not annotate a service-lookup result as `any` — the lookup already returns ' +
73-
'the slot\'s contract (#4168/#4176/#4202), and this switches that checking off ' +
77+
'Do not erase a service-lookup result to `any` (`: any`, `as any`, or a ' +
78+
'`getService<any>(…)` type argument) — the lookup already returns the slot\'s ' +
79+
'contract (#4168/#4176/#4202), and this switches that checking off ' +
7480
'for the call site while looking identical to code that has it. Every such ' +
7581
'annotation found so far was hiding a real gap, including a project-membership ' +
76-
'gate that silently stopped gating. If the slot genuinely has no contract, add ' +
77-
'its name to UNCONTRACTED_SLOTS in eslint.config.mjs with a note, so the ' +
78-
'exemption is reviewed once and visible in one place — see issue #4127.';
82+
'gate that silently stopped gating and two datasource-registration branches ' +
83+
'probing a method no metadata service has (#4251). Pass the slot\'s contract ' +
84+
'type instead (`getService<IDataEngine>(\'data\')`). If the slot genuinely has ' +
85+
'no contract, add its name to UNCONTRACTED_SLOTS in eslint.config.mjs with a ' +
86+
'note, so the exemption is reviewed once and visible in one place — see ' +
87+
'issues #4127 and #4251.';
88+
89+
// [#4251] The sweep ratchet. These files hold pre-existing lookup-erasure
90+
// sites — `getService<any>(…)`, `: any`, or `as any` — that predate the rule
91+
// reaching them: the rule's scope was packages/runtime only until #4251
92+
// widened it, and the type-argument selector did not exist. Enumerated by
93+
// running this config with this list emptied: 180 sites in 44 files (the
94+
// issue's 80 was the non-test `<any>` form alone; the annotation forms and
95+
// test files the old selectors would have caught under a wider scope roughly
96+
// double it). They are grandfathered BY FILE, here, for the same reason
97+
// UNCONTRACTED_SLOTS is central: `--no-inline-config` means the escape must
98+
// live in config, and a shrinking list in one place is the ratchet made
99+
// visible. Batches remove entries as they sweep (see #4214 for the batch
100+
// pattern and its yield — these sites are where the erased contracts live).
101+
// NEVER add an entry: a new file starts covered, and a new violation in a
102+
// listed file rides an existing entry only until its batch.
103+
const SLOT_LOOKUP_UNSWEPT = [
104+
'packages/cli/src/commands/migrate/files-to-references.ts',
105+
'packages/cli/src/commands/migrate/value-shapes.ts',
106+
'packages/cli/src/commands/serve.ts',
107+
'packages/client/src/client.hono.test.ts',
108+
'packages/cloud-connection/src/cloud-connection-plugin.ts',
109+
'packages/cloud-connection/src/marketplace-install-local-plugin.ts',
110+
'packages/core/examples/kernel-features-example.ts',
111+
'packages/metadata-protocol/src/plugin.ts',
112+
'packages/metadata/src/plugin.ts',
113+
'packages/objectql/src/plugin.integration.test.ts',
114+
'packages/objectql/src/plugin.ts',
115+
'packages/plugins/plugin-approvals/src/approvals-plugin.ts',
116+
'packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts',
117+
'packages/plugins/plugin-audit/src/audit-plugin.ts',
118+
'packages/plugins/plugin-auth/src/auth-plugin.ts',
119+
'packages/plugins/plugin-email/src/email-plugin.ts',
120+
'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts',
121+
'packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts',
122+
'packages/plugins/plugin-reports/src/reports-plugin.ts',
123+
'packages/plugins/plugin-security/src/security-plugin.ts',
124+
'packages/plugins/plugin-sharing/src/sharing-plugin.ts',
125+
'packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts',
126+
'packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts',
127+
'packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts',
128+
'packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts',
129+
'packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts',
130+
'packages/rest/src/external-datasource-routes.ts',
131+
'packages/rest/src/rest-api-plugin.ts',
132+
'packages/services/service-datasource/src/admin-routes.ts',
133+
'packages/services/service-job/src/job-service-plugin.ts',
134+
'packages/services/service-messaging/src/messaging-service-plugin.test.ts',
135+
'packages/services/service-messaging/src/messaging-service-plugin.ts',
136+
'packages/services/service-queue/src/queue-service-plugin.ts',
137+
'packages/services/service-realtime/src/realtime-service-plugin.ts',
138+
'packages/services/service-settings/src/settings-service-plugin.ts',
139+
'packages/services/service-sms/src/sms-plugin.ts',
140+
'packages/services/service-storage/src/storage-service-plugin.ts',
141+
'packages/triggers/trigger-record-change/src/formula-context.test.ts',
142+
'packages/triggers/trigger-record-change/src/multilookup-context.test.ts',
143+
'packages/triggers/trigger-record-change/src/record-change-integration.test.ts',
144+
];
79145

80146
export default [
81147
{
@@ -200,15 +266,22 @@ export default [
200266
// having — a deliberate gap is a reviewed line in this file, a careless one
201267
// is a build failure, and the two stop looking identical in the code.
202268
//
269+
// [#4251] Scope is all of packages/ — the rule shipped scoped to
270+
// packages/runtime while the composition roots (rest, plugins/*, services/*)
271+
// held 77 of the 80 known sites, an unlinted majority that looked covered.
272+
// Per-package curation would recreate that gap one package at a time, so the
273+
// scope is total and the not-yet-swept files are grandfathered individually
274+
// in SLOT_LOOKUP_UNSWEPT above — a shrinking list, not a silent boundary.
275+
//
203276
// KNOWN RESIDUAL: a wrapper whose own return type is annotated
204277
// (`const getEngine = async (): Promise<any> => …resolveService(…)`) erases
205278
// the slot type just as effectively, and this selector cannot see it — the
206279
// annotation is on the enclosing function, not on the call. One such site
207280
// existed (share-links `getEngine`, fixed in batch 4). Catching that shape
208281
// needs type information, so it belongs to a typed-lint pass, not here.
209282
{
210-
files: ['packages/runtime/**/*.{ts,mts,cts}'],
211-
ignores: ['**/node_modules/**', '**/dist/**'],
283+
files: ['packages/**/*.{ts,tsx,mts,cts}'],
284+
ignores: ['**/node_modules/**', '**/dist/**', ...SLOT_LOOKUP_UNSWEPT],
212285
languageOptions: {
213286
parser: tsParser,
214287
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
@@ -231,6 +304,17 @@ export default [
231304
`:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/])))`,
232305
message: SLOT_LOOKUP_ANY_MESSAGE,
233306
},
307+
{
308+
// `ctx.getService<any>('data')` — the type-argument form (#4251).
309+
// No annotation, no `as`, and the contract is erased all the same;
310+
// this is the shape 80 sites actually used while the two selectors
311+
// above matched zero of them.
312+
selector:
313+
`CallExpression[callee.property.name=/^(${SLOT_LOOKUPS})$/]` +
314+
'[typeArguments.params.0.type="TSAnyKeyword"]' +
315+
`:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/]))`,
316+
message: SLOT_LOOKUP_ANY_MESSAGE,
317+
},
234318
],
235319
},
236320
},

packages/cli/src/commands/serve.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,9 +1003,15 @@ export default class Serve extends Command {
10031003
});
10041004
if (telemetry.engine !== 'memory') {
10051005
// The engine keys datasources by driver name — the
1006-
// lifecycle router looks this exact name up.
1006+
// lifecycle router looks this exact name up. The driver
1007+
// name is the WHOLE wiring: DriverPlugin.init registers
1008+
// `driver.telemetry`, ObjectQL's discovery loop adopts
1009+
// it, and lifecycle-classed objects route to it. (An
1010+
// options bag once also asked for `datasourceName:
1011+
// 'telemetry'` metadata registration — inert since
1012+
// inception, retired in #4320.)
10071013
Object.defineProperty(telemetry.driver, 'name', { value: 'telemetry' });
1008-
await kernel.use(new DriverPlugin(telemetry.driver, { datasourceName: 'telemetry', registerAsDefault: false }));
1014+
await kernel.use(new DriverPlugin(telemetry.driver));
10091015
trackPlugin('TelemetryDatasource');
10101016
console.log(chalk.dim(` telemetry datasource: ${telemetryPath} (lifecycle-classed system data; OS_TELEMETRY_DB=0 to disable)`));
10111017
}

packages/runtime/src/default-datasource-plugin.test.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
// the real kernel (init-all → start-all) with the real driver factory.
88

99
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
10+
import type { IDataEngine } from '@objectstack/spec/contracts';
1011
import { Runtime } from './runtime.js';
1112
import { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
1213
import { AppPlugin } from './app-plugin.js';
@@ -64,10 +65,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
6465
});
6566
try {
6667
await kernel.bootstrap();
67-
const engine = kernel.getService<any>('data');
68+
const engine = kernel.getService<IDataEngine>('data');
6869
// The driver keeps its NATURAL name (no 'default' stamping) — routing to
6970
// `default` goes through the engine's default-driver fallback.
70-
expect(engine.getDriverByName('default')).toBeUndefined();
71+
expect(engine.getDriverByName?.('default')).toBeUndefined();
7172
await engine.insert('note', { title: 'through-the-default' });
7273
const rows = await engine.find('note');
7374
expect(rows.map((r: any) => r.title)).toContain('through-the-default');
@@ -93,7 +94,7 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
9394
const kernel = await assemble({ withAdminPlugin: false });
9495
try {
9596
await kernel.bootstrap();
96-
const engine = kernel.getService<any>('data');
97+
const engine = kernel.getService<IDataEngine>('data');
9798
await engine.insert('sys_metadata', undefined as never).catch(() => { /* shape probe only */ });
9899
// The default driver exists and the engine can answer a trivial query path.
99100
expect(typeof engine.find).toBe('function');
@@ -133,8 +134,8 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
133134
});
134135
try {
135136
await expect(kernel.bootstrap()).resolves.not.toThrow();
136-
const engine = kernel.getService<any>('data');
137-
expect(engine.getDefaultDriverName()).toBeDefined();
137+
const engine = kernel.getService<IDataEngine>('data');
138+
expect(engine.getDefaultDriverName?.()).toBeDefined();
138139
} finally {
139140
try { await (kernel as any)?.stop?.(); } catch { /* noop */ }
140141
}
@@ -158,11 +159,11 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
158159
});
159160
try {
160161
await kernel.bootstrap();
161-
const engine = kernel.getService<any>('data');
162-
const defaultName = engine.getDefaultDriverName();
162+
const engine = kernel.getService<IDataEngine>('data');
163+
const defaultName = engine.getDefaultDriverName?.();
163164
expect(defaultName).toBeDefined();
164165
// Identity, not equivalence: the engine's default driver IS the host's instance.
165-
expect(engine.getDriverByName(defaultName)).toBe(hostBuilt);
166+
expect(engine.getDriverByName?.(defaultName!)).toBe(hostBuilt);
166167
await engine.insert('note', { title: 'through-the-adopted-default' });
167168
const rows = await engine.find('note');
168169
expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default');
@@ -197,8 +198,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (#
197198
it('kernel teardown disconnects an OWNED (factory-built) default through the one service (#3993)', async () => {
198199
const kernel = await assemble({});
199200
await kernel.bootstrap();
200-
const engine = kernel.getService<any>('data');
201-
const drv = engine.getDriverByName(engine.getDefaultDriverName());
201+
const engine = kernel.getService<IDataEngine>('data');
202+
// The real engine carries the driver registry; `!` asserts exactly that,
203+
// and a missing surface fails the test rather than sliding past it.
204+
const drv = engine.getDriverByName!(engine.getDefaultDriverName!()!)!;
202205
let disconnects = 0;
203206
const orig = drv.disconnect?.bind(drv);
204207
drv.disconnect = async () => { disconnects += 1; return orig?.(); };

0 commit comments

Comments
 (0)