Skip to content

Commit 9f3d43e

Browse files
committed
test(engine): pin that a class-typed object(name) hands back a declared repository
Compiler-driven probes (`ts.createProgram`, the idiom `packages/spec/src/contracts/scoped-context.test.ts` uses) over the exported class doors — `ScopedContext`, `ObjectQL.createContext`, `sudo()` — asserting the diagnostic NAMES the declared shape, so neither a bare "it errored" nor an `any` that erased the type can satisfy it. Anti-vacuity: the legal spelling must compile clean and no probe may report TS2307. Probes go through the CLASS, not `HookContext`: `HookContext.api` was narrowed to `IScopedContext` by #5945, so a `(ctx: HookContext)` probe is green on both sides of this fix and pins nothing. Measured, and recorded in the file header. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
1 parent bd0afc6 commit 9f3d43e

1 file changed

Lines changed: 192 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import ts from 'typescript';
5+
import { dirname, resolve } from 'node:path';
6+
import { fileURLToPath } from 'node:url';
7+
8+
// ─── [#16786] the repository a CLASS-typed call site reaches is declared ────
9+
//
10+
// `IScopedObjectRepository` (`packages/spec/src/contracts/scoped-context.ts`)
11+
// declares `findOne` as `Promise<Record<string, any> | null>` and `update` as
12+
// `Promise<Record<string, any> | number | null>` — ruling A on #16231, landed
13+
// as PR #16783. `IDataEngine`, the call each `ObjectRepository` member forwards
14+
// to, declares the same shapes. `ObjectRepository` sat between those two narrow
15+
// declarations and re-widened the result back to `Promise<any>`.
16+
//
17+
// `implements` does not catch that: a WIDER declared return always satisfies a
18+
// narrower one, so `class ObjectRepository implements IScopedObjectRepository`
19+
// compiled green while the members it published were `any`. The interface's
20+
// narrowing therefore reached only the call sites whose STATIC type is the
21+
// interface — and the doors this package exports are typed as the CLASS:
22+
//
23+
// ObjectQL.createContext(ctx).object(n) -> ScopedContext -> ObjectRepository
24+
// ScopedContext.sudo().object(n) -> ObjectRepository
25+
// engine.transaction((trxCtx) => …) -> ScopedContext -> ObjectRepository
26+
//
27+
// ## What this file measures, and what it deliberately does not
28+
//
29+
// Measured on `origin/main` ae19f5edb7 before the fix, with these probes:
30+
//
31+
// ctx: HookContext ; ctx.api!.object(n).findOne(…) -> ALREADY NARROW
32+
// api: ScopedContext ; api.object(n).findOne(…) -> `any`
33+
// ql.createContext({}).object(n).findOne(…) -> `any`
34+
//
35+
// ⚠️ The first line is why the probes below are written through the CLASS and
36+
// the exported engine door rather than through `HookContext`. `HookContext.api`
37+
// was narrowed to `IScopedContext` by #5945/#6311, so a handler typed
38+
// `(ctx: HookContext) => …` reads the narrow type today and read it before this
39+
// fix too — a probe written that way is GREEN on both sides and pins nothing.
40+
// The `any` lives on the class-typed doors, so that is where the probes go.
41+
//
42+
// ## Why the compiler API rather than `@ts-expect-error`
43+
//
44+
// The same reason `packages/spec/src/contracts/scoped-context.test.ts` gives:
45+
// `@ts-expect-error` is satisfied by ANY error on the next line, and this
46+
// file's whole subject is WHICH type a call resolves to. Every negative probe
47+
// below asserts that the diagnostic NAMES the declared shape, so a bare "it
48+
// errored" — or an `any` that erased the type entirely — cannot satisfy it.
49+
//
50+
// Anti-vacuity: a harness that resolves nothing reports zero diagnostics and
51+
// looks exactly like success, so `control-legal` must compile CLEAN, and no
52+
// probe may report TS2307 (unresolved module).
53+
54+
const HERE = dirname(fileURLToPath(import.meta.url));
55+
const PKG = resolve(HERE, '..');
56+
57+
/**
58+
* Compile probe files against this package's real `src/engine.ts` and return
59+
* each one's diagnostics. The probes live (virtually) beside the source, so
60+
* `../engine` resolves the way any sibling module would and `@objectstack/spec`
61+
* resolves the way a real consumer's does — through the installed package.
62+
*/
63+
function compileProbes(probes: Readonly<Record<string, string>>): Map<string, ts.Diagnostic[]> {
64+
const dir = resolve(PKG, 'src/__scoped_repo_probes__');
65+
const paths = new Map<string, string>();
66+
for (const [name, text] of Object.entries(probes)) paths.set(resolve(dir, `${name}.ts`), text);
67+
68+
const options: ts.CompilerOptions = {
69+
target: ts.ScriptTarget.ES2020,
70+
module: ts.ModuleKind.ESNext,
71+
moduleResolution: ts.ModuleResolutionKind.Bundler,
72+
strict: true,
73+
skipLibCheck: true,
74+
noEmit: true,
75+
// A probe declares a const and stops; TS6133 is an opinion about the
76+
// probe's framing, not about whether the call is well-typed.
77+
noUnusedLocals: false,
78+
noUnusedParameters: false,
79+
types: ['node'],
80+
baseUrl: PKG,
81+
};
82+
83+
const host = ts.createCompilerHost(options, true);
84+
const realGetSourceFile = host.getSourceFile.bind(host);
85+
const realFileExists = host.fileExists.bind(host);
86+
const realReadFile = host.readFile.bind(host);
87+
host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => {
88+
const overlay = paths.get(resolve(fileName));
89+
return overlay === undefined
90+
? realGetSourceFile(fileName, languageVersion, onError, shouldCreate)
91+
: ts.createSourceFile(fileName, overlay, languageVersion, true);
92+
};
93+
host.fileExists = (fileName) => paths.has(resolve(fileName)) || realFileExists(fileName);
94+
host.readFile = (fileName) => paths.get(resolve(fileName)) ?? realReadFile(fileName);
95+
96+
const program = ts.createProgram([...paths.keys()], options, host);
97+
const out = new Map<string, ts.Diagnostic[]>();
98+
for (const name of Object.keys(probes)) out.set(name, []);
99+
for (const d of ts.getPreEmitDiagnostics(program)) {
100+
const file = d.file?.fileName ? resolve(d.file.fileName) : undefined;
101+
for (const name of Object.keys(probes)) {
102+
if (file === resolve(dir, `${name}.ts`)) out.get(name)!.push(d);
103+
}
104+
}
105+
return out;
106+
}
107+
108+
/** One diagnostic per line, `TS<code>: <message>`, for readable assertions. */
109+
function render(diagnostics: readonly ts.Diagnostic[]): string {
110+
return diagnostics
111+
.map((d) => `TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`)
112+
.join('\n');
113+
}
114+
115+
const PROBES = {
116+
// ── anti-vacuity: the harness really resolves and really compiles ────────
117+
'control-legal': `
118+
import type { ObjectQL } from '../engine';
119+
export const probe = async (ql: ObjectQL) => {
120+
const row = await ql.createContext({}).object('task').findOne({ where: { id: 't1' } });
121+
return row === null ? 'missing' : String(row.status);
122+
};`,
123+
// ── the class-typed door: what a hook reaches at RUNTIME ─────────────────
124+
'class-door-findOne': `
125+
import type { ScopedContext } from '../engine';
126+
export const probe = async (api: ScopedContext) => {
127+
const bad: number = await api.object('task').findOne({ where: { id: 't1' } });
128+
};`,
129+
// ── the exported public door ─────────────────────────────────────────────
130+
'public-door-findOne': `
131+
import type { ObjectQL } from '../engine';
132+
export const probe = async (ql: ObjectQL) => {
133+
const bad: number = await ql.createContext({}).object('task').findOne({ where: { id: 't1' } });
134+
};`,
135+
// ── the elevated door ────────────────────────────────────────────────────
136+
'sudo-door-findOne': `
137+
import type { ScopedContext } from '../engine';
138+
export const probe = async (api: ScopedContext) => {
139+
const bad: number = await api.sudo().object('task').findOne({ where: { id: 't1' } });
140+
};`,
141+
// ── update carries the same repair ───────────────────────────────────────
142+
'class-door-update': `
143+
import type { ScopedContext } from '../engine';
144+
export const probe = async (api: ScopedContext) => {
145+
const bad: boolean = await api.object('task').update({ id: 't1', status: 'done' });
146+
};`,
147+
// ── the direct any-detector, in case a future edit reaches `any` by ──────
148+
// ── some route the assignment probes above do not cover ─────────────────
149+
'not-any-findOne': `
150+
import type { ScopedContext } from '../engine';
151+
type IsAny<T> = 0 extends (1 & T) ? true : false;
152+
type Row = Awaited<ReturnType<ReturnType<ScopedContext['object']>['findOne']>>;
153+
export const isAny: IsAny<Row> = true;`,
154+
} as const;
155+
156+
describe('[#16786] `object(name)` hands back a DECLARED repository, not `any`', () => {
157+
const diagnostics = compileProbes(PROBES);
158+
159+
it('resolves every probe against real source (anti-vacuity)', () => {
160+
for (const [name, ds] of diagnostics) {
161+
expect(render(ds), `${name} failed to resolve its imports`).not.toContain('TS2307');
162+
}
163+
// The legal spelling — null handled — must compile with nothing to say.
164+
// Without this, a harness that compiled nothing would satisfy every
165+
// negative probe below by reporting no diagnostics at all.
166+
expect(render(diagnostics.get('control-legal')!)).toBe('');
167+
});
168+
169+
it.each([
170+
['class-door-findOne', 'Record<string, any> | null'],
171+
['public-door-findOne', 'Record<string, any> | null'],
172+
['sudo-door-findOne', 'Record<string, any> | null'],
173+
// TypeScript normalises this union's order; the string is the compiler's
174+
// own rendering, not the source order in `IScopedObjectRepository`.
175+
['class-door-update', 'number | Record<string, any> | null'],
176+
])('%s: the diagnostic NAMES the declared shape', (probe, declared) => {
177+
const text = render(diagnostics.get(probe)!);
178+
// Not merely "some error": `any` produces NO error here, and an erased or
179+
// widened declaration produces one that does not name this shape.
180+
expect(text).toContain('TS2322');
181+
expect(text).toContain(declared);
182+
});
183+
184+
it('findOne is not `any` — measured by the compiler, not by reading the source', () => {
185+
// `IsAny<Row>` is `false` once the declaration is honest, so assigning
186+
// `true` to it is an error. When `Row` is `any`, `IsAny<Row>` is `true`
187+
// and this probe compiles clean — which is the pre-fix reading.
188+
const text = render(diagnostics.get('not-any-findOne')!);
189+
expect(text).toContain('TS2322');
190+
expect(text).toContain("Type 'true' is not assignable to type 'false'");
191+
});
192+
});

0 commit comments

Comments
 (0)