@fougere/compiler 0.11.0-alpha.0 → 0.12.1-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,174 +4,10 @@ import { type ErrorCode } from '@fougere/core';
4
4
 
5
5
  import type ts from '@typescript/typescript6';
6
6
  import { refusalsIn } from './refusals.js';
7
+ import { loadTS, getTS, checkedSourceOf, sourceOf, findDefaultClass, projectOf } from './TypeProgram.js';
7
8
  import { readFileSync, existsSync, statSync } from 'node:fs';
8
9
  import { join, dirname, resolve as resolvePath } from 'node:path';
9
10
 
10
- /** Lazy-loaded TypeScript module — avoids bundling the 9MB compiler. */
11
- let _ts: typeof ts | undefined;
12
- async function loadTS(): Promise<typeof ts> {
13
- if (!_ts) _ts = (await import('@typescript/typescript6')).default;
14
- return _ts;
15
- }
16
- function getTS(): typeof ts {
17
- if (!_ts) throw new Error('TypeScript not loaded — call an async parse function first');
18
- return _ts;
19
- }
20
-
21
- interface TypeProject {
22
- roots: Set<string>;
23
- options: ts.CompilerOptions;
24
- program: ts.Program;
25
- }
26
-
27
- /** One checked program per project/configuration during a scan. */
28
- const typeProjects = new Map<string, TypeProject>();
29
- const compilerProjects = new Map<string, { key: string; roots: string[]; options: ts.CompilerOptions }>();
30
-
31
- /** What survives a run. */
32
- const sourceFiles = new Map<string, { mtime: number; file: ts.SourceFile }>();
33
- const retained = new Map<string, { host: ts.CompilerHost; program: ts.Program }>();
34
-
35
- export function resetTypePrograms(): void {
36
- typeProjects.clear();
37
- compilerProjects.clear();
38
- }
39
-
40
- function keptHost(key: string, options: ts.CompilerOptions): ts.CompilerHost {
41
- const cached = retained.get(key);
42
- if (cached) return cached.host;
43
- const typescript = getTS();
44
- const base = typescript.createCompilerHost(options);
45
- const host: ts.CompilerHost = {
46
- ...base,
47
- getSourceFile(fileName, languageVersion, onError, shouldCreate) {
48
- const path = resolvePath(fileName);
49
- const mtime = statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? -1;
50
- const cached = sourceFiles.get(path);
51
- if (cached && cached.mtime === mtime) return cached.file;
52
- const file = base.getSourceFile(fileName, languageVersion, onError, shouldCreate);
53
- if (file && mtime >= 0) sourceFiles.set(path, { mtime, file });
54
- return file;
55
- },
56
- };
57
- retained.set(key, { host, program: undefined as unknown as ts.Program });
58
- return host;
59
- }
60
-
61
- function builtProgram(key: string, roots: readonly string[], options: ts.CompilerOptions): ts.Program {
62
- const typescript = getTS();
63
- const host = keptHost(key, options);
64
- const program = typescript.createProgram({
65
- rootNames: [...roots], options, host, oldProgram: retained.get(key)?.program,
66
- });
67
- retained.set(key, { host, program });
68
- return program;
69
- }
70
-
71
- function compilerProjectOf(filePath: string, projectRoot?: string): { key: string; roots: string[]; options: ts.CompilerOptions } {
72
- const typescript = getTS();
73
- const absolute = resolvePath(filePath);
74
- const configPath = typescript.findConfigFile(dirname(absolute), typescript.sys.fileExists);
75
-
76
- if (configPath) {
77
- const key = `${configPath}:${projectRoot ?? ''}`;
78
- const cached = compilerProjects.get(key);
79
- if (cached) return cached;
80
-
81
- const read = typescript.readConfigFile(configPath, typescript.sys.readFile);
82
- if (read.error) throw new Error(typescript.flattenDiagnosticMessageText(read.error.messageText, '\n'));
83
- const parsed = typescript.parseJsonConfigFileContent(read.config, typescript.sys, dirname(configPath));
84
- const configured = {
85
- key,
86
- // Compiler options belong to the project; its entire include glob does not belong
87
- // to this scan. Each declaration inspected below becomes a root and TypeScript
88
- // follows its imports. Seeding the monorepo here made a one-file scan compile it all.
89
- roots: [],
90
- // Handlers may be authored or emitted as JavaScript. They still need to belong to
91
- // the checked program so constructor parsing does not fail on the first cold scan.
92
- options: { ...parsed.options, allowJs: true, noEmit: true },
93
- };
94
- compilerProjects.set(key, configured);
95
- return configured;
96
- }
97
-
98
- const key = projectRoot ?? dirname(absolute);
99
- const cached = compilerProjects.get(key);
100
- if (cached) return cached;
101
- const configured = {
102
- key,
103
- roots: [],
104
- options: {
105
- target: typescript.ScriptTarget.ES2022,
106
- module: typescript.ModuleKind.Node16,
107
- moduleResolution: typescript.ModuleResolutionKind.Node16,
108
- strict: true,
109
- skipLibCheck: true,
110
- allowJs: true,
111
- noEmit: true,
112
- },
113
- };
114
- compilerProjects.set(key, configured);
115
- return configured;
116
- }
117
-
118
- /** Declare every file a run will read, so one program covers it. */
119
- export async function seedTypeProgram(filePaths: readonly string[], projectRoot?: string): Promise<void> {
120
- const typescript = await loadTS();
121
- const grouped = new Map<string, { options: ts.CompilerOptions; paths: string[] }>();
122
-
123
- for (const filePath of filePaths) {
124
- const absolute = resolvePath(filePath);
125
- const typescript = getTS();
126
- const configured = compilerProjectOf(absolute, projectRoot);
127
- const group = grouped.get(configured.key) ?? { options: configured.options, paths: [] };
128
- group.paths.push(absolute);
129
- grouped.set(configured.key, group);
130
- }
131
-
132
- for (const [key, { options, paths }] of grouped) {
133
- const roots = new Set(typeProjects.get(key)?.roots ?? []);
134
- for (const path of paths) roots.add(path);
135
- typeProjects.set(key, { roots, options, program: builtProgram(key, [...roots], options) });
136
- }
137
- }
138
-
139
- /** The program a file belongs to, built once and widened as more files are asked for. */
140
- function projectOf(filePath: string, projectRoot?: string): { program: ts.Program; absolute: string } {
141
- const absolute = resolvePath(filePath);
142
- const configured = compilerProjectOf(absolute, projectRoot);
143
- let project = typeProjects.get(configured.key);
144
-
145
- if (!project) {
146
- // `path.resolve` is variadic, so handing it directly to `map` also passed the
147
- // index and the whole roots array as path segments. A fixture without a warm scan
148
- // cache exposed that first-run-only failure.
149
- const roots = new Set(configured.roots.map((root) => resolvePath(root)));
150
- roots.add(absolute);
151
- const program = builtProgram(configured.key, [...roots], configured.options);
152
- project = { roots, options: configured.options, program };
153
- typeProjects.set(configured.key, project);
154
- } else if (!project.roots.has(absolute)) {
155
- project.roots.add(absolute);
156
- project.program = builtProgram(configured.key, [...project.roots], project.options);
157
- }
158
-
159
- return { program: project.program, absolute };
160
- }
161
-
162
- function checkedSourceOf(filePath: string, projectRoot?: string): { source: ts.SourceFile; checker: ts.TypeChecker } {
163
- const { program, absolute } = projectOf(filePath, projectRoot);
164
- const source = program.getSourceFile(absolute);
165
- if (!source) throw new Error(`TypeScript did not include '${absolute}' in its program.`);
166
-
167
- return { source, checker: program.getTypeChecker() };
168
- }
169
-
170
- /** A file, opened. Five places read and parsed one, each spelling the same two calls. */
171
- function sourceOf(filePath: string): ts.SourceFile {
172
- const ts = getTS();
173
- return ts.createSourceFile(filePath, readFileSync(filePath, 'utf-8'), ts.ScriptTarget.Latest, true);
174
- }
175
11
 
176
12
  /** One declared parameter — a constructor's and a method's are read the same way. */
177
13
  function parsedParam(param: ts.ParameterDeclaration, source: ts.SourceFile, checker?: ts.TypeChecker): Param {
@@ -194,75 +30,81 @@ function parseTypeNode(node: ts.TypeNode, source: ts.SourceFile, checker?: ts.Ty
194
30
  const ts = getTS();
195
31
  const raw = node.getText(source);
196
32
 
197
- if (checker) {
198
- // `Fact<T>` and `Pipe<T>` are deliberately transparent in TypeScript (`= T`), because
199
- // both receive the payload itself — one reads it, the other answers the value every
200
- // reader then gets. The checker erases the marker, while the binding plan still needs
201
- // it to tell either from an ordinary body.
202
- if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && ANNOUNCED.has(node.typeName.text)) {
203
- return {
204
- raw,
205
- name: node.typeName.text,
206
- generics: node.typeArguments?.map((arg) => parseTypeNode(arg, source, checker)) ?? [],
207
- };
208
- }
209
- return parseCheckedType(checker.getTypeFromTypeNode(node), raw, checker);
33
+ if (checker) return announced(node, source, checker) ?? parseCheckedType(checker.getTypeFromTypeNode(node), raw, checker);
34
+
35
+ if (ts.isUnionTypeNode(node)) return fromUnion(node, source, raw);
36
+ if (ts.isTypeReferenceNode(node)) return fromReference(node, source, raw);
37
+ if (ts.isArrayTypeNode(node)) {
38
+ const inner = parseTypeNode(node.elementType, source);
39
+
40
+ return { ...inner, array: true, arrayDepth: (inner.arrayDepth ?? 0) + 1, raw };
210
41
  }
211
42
 
212
- // Union types — extract nullable, strip Promise
213
- if (ts.isUnionTypeNode(node)) {
214
- const nonNull = node.types.filter(
215
- (t) => !(ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword)
216
- && !(t.kind === ts.SyntaxKind.UndefinedKeyword)
217
- && !(t.kind === ts.SyntaxKind.VoidKeyword)
218
- && !(t.kind === ts.SyntaxKind.NullKeyword),
219
- );
220
- const nullable = node.types.some((t) =>
221
- (ts.isLiteralTypeNode(t) && t.literal.kind === ts.SyntaxKind.NullKeyword)
222
- || t.kind === ts.SyntaxKind.NullKeyword,
223
- );
224
- const undefinable = node.types.some((t) =>
225
- t.kind === ts.SyntaxKind.UndefinedKeyword || t.kind === ts.SyntaxKind.VoidKeyword,
226
- );
227
- if (nonNull.length === 1) {
228
- const inner = parseTypeNode(nonNull[0], source);
229
- return { ...inner, nullable: nullable || inner.nullable, undefined: undefinable || inner.undefined, raw };
230
- }
231
- return { raw, name: raw, nullable, undefined: undefinable };
43
+ return fromKeyword(node, raw) ?? { raw, name: raw };
44
+ }
45
+
46
+ /**
47
+ * `Fact<T>` and `Pipe<T>` are deliberately transparent in TypeScript (`= T`), because both
48
+ * receive the payload itself — one reads it, the other answers the value every reader then gets.
49
+ * The checker erases the marker, while the binding plan still needs it to tell either from an
50
+ * ordinary body.
51
+ */
52
+ function announced(node: ts.TypeNode, source: ts.SourceFile, checker: ts.TypeChecker): TypeRef | undefined {
53
+ const ts = getTS();
54
+ if (!ts.isTypeReferenceNode(node) || !ts.isIdentifier(node.typeName) || !ANNOUNCED.has(node.typeName.text)) {
55
+ return undefined;
232
56
  }
233
57
 
234
- // Promise<T> — unwrap
235
- if (ts.isTypeReferenceNode(node)) {
236
- const typeName = node.typeName.getText(source);
58
+ return {
59
+ raw: node.getText(source),
60
+ name: node.typeName.text,
61
+ generics: node.typeArguments?.map((arg) => parseTypeNode(arg, source, checker)) ?? [],
62
+ };
63
+ }
237
64
 
238
- if (typeName === 'Promise' && node.typeArguments?.length === 1) {
239
- const inner = parseTypeNode(node.typeArguments[0], source);
240
- return { ...inner, promise: true, raw };
241
- }
65
+ /** `T | null`, `T | undefined`, `T | void` — the absence is an axis, the rest is the type. */
66
+ function fromUnion(node: ts.UnionTypeNode, source: ts.SourceFile, raw: string): TypeRef {
67
+ const ts = getTS();
68
+ const isNull = (one: ts.TypeNode) =>
69
+ (ts.isLiteralTypeNode(one) && one.literal.kind === ts.SyntaxKind.NullKeyword)
70
+ || one.kind === ts.SyntaxKind.NullKeyword;
71
+ const isAbsent = (one: ts.TypeNode) =>
72
+ one.kind === ts.SyntaxKind.UndefinedKeyword || one.kind === ts.SyntaxKind.VoidKeyword;
242
73
 
243
- // Array<T>
244
- if (typeName === 'Array' && node.typeArguments?.length === 1) {
245
- const inner = parseTypeNode(node.typeArguments[0], source);
246
- return { ...inner, array: true, arrayDepth: (inner.arrayDepth ?? 0) + 1, raw };
247
- }
74
+ const nullable = node.types.some(isNull);
75
+ const undefinable = node.types.some(isAbsent);
76
+ const nonNull = node.types.filter((one) => !isNull(one) && !isAbsent(one));
248
77
 
249
- // Generic type: Foo<Bar, Baz>
250
- if (node.typeArguments && node.typeArguments.length > 0) {
251
- const generics = node.typeArguments.map((arg) => parseTypeNode(arg, source));
252
- return { raw, name: typeName, generics };
253
- }
78
+ if (nonNull.length !== 1) return { raw, name: raw, nullable, undefined: undefinable };
79
+
80
+ const inner = parseTypeNode(nonNull[0]!, source);
81
+
82
+ return { ...inner, nullable: nullable || inner.nullable, undefined: undefinable || inner.undefined, raw };
83
+ }
254
84
 
255
- // Simple type reference: Post, string, etc.
256
- return { raw, name: typeName };
85
+ /** `Promise<T>` and `Array<T>` are carriers; anything else keeps its name and its generics. */
86
+ function fromReference(node: ts.TypeReferenceNode, source: ts.SourceFile, raw: string): TypeRef {
87
+ const typeName = node.typeName.getText(source);
88
+
89
+ if (typeName === 'Promise' && node.typeArguments?.length === 1) {
90
+ return { ...parseTypeNode(node.typeArguments[0]!, source), promise: true, raw };
257
91
  }
258
92
 
259
- // T[]
260
- if (ts.isArrayTypeNode(node)) {
261
- const inner = parseTypeNode(node.elementType, source);
93
+ if (typeName === 'Array' && node.typeArguments?.length === 1) {
94
+ const inner = parseTypeNode(node.typeArguments[0]!, source);
95
+
262
96
  return { ...inner, array: true, arrayDepth: (inner.arrayDepth ?? 0) + 1, raw };
263
97
  }
264
98
 
265
- // Keyword types: string, number, boolean, void
99
+ if (node.typeArguments && node.typeArguments.length > 0) {
100
+ return { raw, name: typeName, generics: node.typeArguments.map((arg) => parseTypeNode(arg, source)) };
101
+ }
102
+
103
+ return { raw, name: typeName };
104
+ }
105
+
106
+ function fromKeyword(node: ts.TypeNode, raw: string): TypeRef | undefined {
107
+ const ts = getTS();
266
108
  switch (node.kind) {
267
109
  case ts.SyntaxKind.StringKeyword: return { raw, name: 'string' };
268
110
  case ts.SyntaxKind.NumberKeyword: return { raw, name: 'number' };
@@ -272,15 +114,8 @@ function parseTypeNode(node: ts.TypeNode, source: ts.SourceFile, checker?: ts.Ty
272
114
  case ts.SyntaxKind.NullKeyword: return { raw, name: 'null', nullable: true };
273
115
  case ts.SyntaxKind.AnyKeyword: return { raw, name: 'any' };
274
116
  case ts.SyntaxKind.UnknownKeyword: return { raw, name: 'unknown' };
117
+ default: return undefined;
275
118
  }
276
-
277
- // Object literal type: { title: string; limit: number }
278
- if (ts.isTypeLiteralNode(node)) {
279
- return { raw, name: raw };
280
- }
281
-
282
- // Fallback
283
- return { raw, name: raw };
284
119
  }
285
120
 
286
121
  function meaningfulSymbolName(type: ts.Type, checker: ts.TypeChecker): string {
@@ -305,41 +140,10 @@ function parseCheckedType(type: ts.Type, raw: string, checker: ts.TypeChecker, d
305
140
  const typescript = getTS();
306
141
  if (depth > 12) return { raw, name: checker.typeToString(type) };
307
142
 
308
- if (type.isUnion()) {
309
- const nullable = type.types.some((member) => (member.flags & typescript.TypeFlags.Null) !== 0);
310
- const undefinable = type.types.some((member) =>
311
- (member.flags & (typescript.TypeFlags.Undefined | typescript.TypeFlags.Void)) !== 0,
312
- );
313
- const members = type.types.filter((member) =>
314
- (member.flags & (typescript.TypeFlags.Null | typescript.TypeFlags.Undefined | typescript.TypeFlags.Void)) === 0,
315
- );
316
- // The checker represents the `boolean` keyword itself as `false | true`. Preserve
317
- // the primitive vocabulary consumed by binding and presenter metadata.
318
- if (
319
- members.length > 0
320
- && members.every((member) => (member.flags & typescript.TypeFlags.BooleanLiteral) !== 0)
321
- ) {
322
- return { raw, name: 'boolean', nullable, undefined: undefinable };
323
- }
324
- if (members.length === 1) {
325
- const inner = parseCheckedType(members[0]!, raw, checker, depth + 1);
326
- return {
327
- ...inner,
328
- raw,
329
- nullable: nullable || inner.nullable,
330
- undefined: undefinable || inner.undefined,
331
- };
332
- }
333
- return {
334
- raw,
335
- name: members.map((member) => meaningfulSymbolName(member, checker)).join(' | ') || raw,
336
- nullable,
337
- undefined: undefinable,
338
- };
339
- }
143
+ if (type.isUnion()) return fromCheckedUnion(type, raw, checker, depth);
340
144
 
341
- // Present at runtime in the supported compiler versions, but intentionally omitted
342
- // from TypeScript's public TypeChecker declaration.
145
+ // Present at runtime in the supported compiler versions, but intentionally omitted from
146
+ // TypeScript's public TypeChecker declaration.
343
147
  const promised = (checker as ts.TypeChecker & {
344
148
  getPromisedTypeOfPromise(candidate: ts.Type): ts.Type | undefined;
345
149
  }).getPromisedTypeOfPromise(type);
@@ -348,24 +152,19 @@ function parseCheckedType(type: ts.Type, raw: string, checker: ts.TypeChecker, d
348
152
  if (checker.isArrayType(type)) {
349
153
  const [element] = checker.getTypeArguments(type as ts.TypeReference);
350
154
  const inner = element ? parseCheckedType(element, raw, checker, depth + 1) : { raw, name: 'unknown' };
155
+
351
156
  return { ...inner, raw, array: true, arrayDepth: (inner.arrayDepth ?? 0) + 1 };
352
157
  }
353
158
 
354
- if ((type.flags & typescript.TypeFlags.StringLike) !== 0) return { raw, name: 'string' };
355
- if ((type.flags & typescript.TypeFlags.NumberLike) !== 0) return { raw, name: 'number' };
356
- if ((type.flags & typescript.TypeFlags.BooleanLike) !== 0) return { raw, name: 'boolean' };
357
- if ((type.flags & typescript.TypeFlags.Void) !== 0) return { raw, name: 'void', undefined: true };
358
- if ((type.flags & typescript.TypeFlags.Undefined) !== 0) return { raw, name: 'undefined', undefined: true };
359
- if ((type.flags & typescript.TypeFlags.Null) !== 0) return { raw, name: 'null', nullable: true };
360
- if ((type.flags & typescript.TypeFlags.Any) !== 0) return { raw, name: 'any' };
361
- if ((type.flags & typescript.TypeFlags.Unknown) !== 0) return { raw, name: 'unknown' };
362
-
363
- const reference = type as ts.TypeReference;
364
- // A declared alias such as `Emit<PostPublished>` may reduce to a function or object
365
- // type, so it is no longer a TypeReference. The checker keeps its arguments beside
366
- // `aliasSymbol`; losing them turns DI keys into bare `Emit`/`Facade`.
159
+ const primitive = fromCheckedFlags(type.flags, raw);
160
+ if (primitive) return primitive;
161
+
162
+ // A declared alias such as `Emit<PostPublished>` may reduce to a function or object type, so it
163
+ // is no longer a TypeReference. The checker keeps its arguments beside `aliasSymbol`; losing
164
+ // them turns DI keys into bare `Emit`/`Facade`.
367
165
  const args = (type as ts.Type & { aliasTypeArguments?: readonly ts.Type[] }).aliasTypeArguments
368
- ?? checker.getTypeArguments(reference);
166
+ ?? checker.getTypeArguments(type as ts.TypeReference);
167
+
369
168
  return {
370
169
  raw,
371
170
  name: meaningfulSymbolName(type, checker),
@@ -373,6 +172,153 @@ function parseCheckedType(type: ts.Type, raw: string, checker: ts.TypeChecker, d
373
172
  };
374
173
  }
375
174
 
175
+ /**
176
+ * What an operation ANSWERS is read here, and the question asked of it is whether it is data.
177
+ *
178
+ * Data is what JSON keeps, so every node is asked one thing: can something in it be CALLED? A
179
+ * `Date` carries `toString`, a `Map` carries `clear`, an Effect carries `pipe`. Measured
180
+ * 2026-09-18 over the facade's own exit: `{ at: new Date(0) }` reaches a local caller as a `Date`
181
+ * and a remote one as a string, and an object with methods loses them on both sides, differently.
182
+ *
183
+ * It answers the PATH and the type sitting there — `at: Date` reads as the declaration it came
184
+ * from, where the first callable member (`toString`) names nothing an author wrote. `any` and
185
+ * `unknown` are left alone: nothing was declared to judge.
186
+ *
187
+ * Documented: [handlers](https://fougere.dev/docs/business/handlers).
188
+ */
189
+ function notData(
190
+ type: ts.Type,
191
+ path: string,
192
+ checker: ts.TypeChecker,
193
+ seen = new Set<ts.Type>(),
194
+ ): string | undefined {
195
+ const typescript = getTS();
196
+ if (seen.has(type)) return undefined;
197
+
198
+ const judged = typescript.TypeFlags.Any | typescript.TypeFlags.Unknown | typescript.TypeFlags.Never
199
+ | typescript.TypeFlags.StringLike | typescript.TypeFlags.NumberLike | typescript.TypeFlags.BooleanLike
200
+ | typescript.TypeFlags.BigIntLike | typescript.TypeFlags.Null | typescript.TypeFlags.Undefined
201
+ | typescript.TypeFlags.Void;
202
+ if (type.flags & judged) return undefined;
203
+
204
+ if (type.isUnion() || type.isIntersection()) return firstRefused(type.types, (member) => notData(member, path, checker, seen));
205
+
206
+ if (type.getCallSignatures().length > 0) return `${path}: ${checker.typeToString(type)}`;
207
+ if (declaresShape(type)) return undefined;
208
+
209
+ seen.add(type);
210
+
211
+ const items = itemsOf(type, checker);
212
+ if (items) return firstRefused(items, (item) => notData(item, `${path}[]`, checker, seen));
213
+
214
+ return firstRefused(checker.getPropertiesOfType(type), (property) => {
215
+ const held = checker.getTypeOfSymbol(property);
216
+ // A symbol key never survives JSON either, and it is how an iterable declares itself.
217
+ if (property.escapedName.toString().startsWith('__@') || held.getCallSignatures().length > 0) {
218
+ return `${path}: ${checker.typeToString(type)}`;
219
+ }
220
+
221
+ return notData(held, `${path}.${property.name}`, checker, seen);
222
+ });
223
+ }
224
+
225
+ /**
226
+ * A class the schema declares — `extends entity({…})`, or a projection of one.
227
+ *
228
+ * Read from the HERITAGE rather than from what `resolveSchema` resolved: a frond answering with
229
+ * an entity its neighbour declares (a `Pipe<PostPublished>` finishing a fact) has no schema in
230
+ * its own module exports, so the op carries no output and would be judged as if nothing
231
+ * converted it. Its fields do convert it, wherever the class was written.
232
+ */
233
+ function declaresShape(type: ts.Type): boolean {
234
+ const typescript = getTS();
235
+
236
+ return (type.getSymbol()?.declarations ?? []).some((declaration) =>
237
+ typescript.isClassDeclaration(declaration)
238
+ && (declaration.heritageClauses ?? []).some((clause) =>
239
+ /entity\(|\.(pick|omit|partial|extend)\(/.test(clause.getText())));
240
+ }
241
+
242
+ /**
243
+ * What a type holds if it is read as a list — its elements, or nothing.
244
+ *
245
+ * By its numeric INDEX and not by `isArrayType`: `ListResult<T> extends Array<T>` is a subtype,
246
+ * so the array test says no and the walk reached `push` and `map` instead of the rows. What rides
247
+ * beside the rows (`total`, `hasMore`) is data and is judged with everything else.
248
+ */
249
+ function itemsOf(type: ts.Type, checker: ts.TypeChecker): readonly ts.Type[] | undefined {
250
+ const typescript = getTS();
251
+ if (checker.isArrayType(type) || checker.isTupleType(type)) {
252
+ return checker.getTypeArguments(type as ts.TypeReference);
253
+ }
254
+ const numeric = checker.getIndexTypeOfType(type, typescript.IndexKind.Number);
255
+
256
+ return numeric ? [numeric] : undefined;
257
+ }
258
+
259
+ function firstRefused<T>(members: readonly T[], read: (member: T) => string | undefined): string | undefined {
260
+ for (const member of members) {
261
+ const refused = read(member);
262
+ if (refused) return refused;
263
+ }
264
+
265
+ return undefined;
266
+ }
267
+
268
+ /** The type an operation hands back, past the promise every dispatch awaits. */
269
+ function answeredBy(signature: ts.Signature | undefined, checker: ts.TypeChecker): ts.Type | undefined {
270
+ if (!signature) return undefined;
271
+ const returned = signature.getReturnType();
272
+
273
+ return checker.getAwaitedType(returned) ?? returned;
274
+ }
275
+
276
+ /** A union carries an absence on the side; what remains is the type. */
277
+ function fromCheckedUnion(type: ts.UnionType, raw: string, checker: ts.TypeChecker, depth: number): TypeRef {
278
+ const typescript = getTS();
279
+ const nullable = type.types.some((member) => (member.flags & typescript.TypeFlags.Null) !== 0);
280
+ const undefinable = type.types.some((member) =>
281
+ (member.flags & (typescript.TypeFlags.Undefined | typescript.TypeFlags.Void)) !== 0,
282
+ );
283
+ const members = type.types.filter((member) =>
284
+ (member.flags & (typescript.TypeFlags.Null | typescript.TypeFlags.Undefined | typescript.TypeFlags.Void)) === 0,
285
+ );
286
+
287
+ // The checker represents the `boolean` keyword itself as `false | true`. Preserve the
288
+ // primitive vocabulary consumed by binding and presenter metadata.
289
+ const allBooleans = members.length > 0
290
+ && members.every((member) => (member.flags & typescript.TypeFlags.BooleanLiteral) !== 0);
291
+ if (allBooleans) return { raw, name: 'boolean', nullable, undefined: undefinable };
292
+
293
+ if (members.length === 1) {
294
+ const inner = parseCheckedType(members[0]!, raw, checker, depth + 1);
295
+
296
+ return { ...inner, raw, nullable: nullable || inner.nullable, undefined: undefinable || inner.undefined };
297
+ }
298
+
299
+ return {
300
+ raw,
301
+ name: members.map((member) => meaningfulSymbolName(member, checker)).join(' | ') || raw,
302
+ nullable,
303
+ undefined: undefinable,
304
+ };
305
+ }
306
+
307
+ /** The keywords, read off the flags the checker already carries. */
308
+ function fromCheckedFlags(flags: ts.TypeFlags, raw: string): TypeRef | undefined {
309
+ const typescript = getTS();
310
+ if ((flags & typescript.TypeFlags.StringLike) !== 0) return { raw, name: 'string' };
311
+ if ((flags & typescript.TypeFlags.NumberLike) !== 0) return { raw, name: 'number' };
312
+ if ((flags & typescript.TypeFlags.BooleanLike) !== 0) return { raw, name: 'boolean' };
313
+ if ((flags & typescript.TypeFlags.Void) !== 0) return { raw, name: 'void', undefined: true };
314
+ if ((flags & typescript.TypeFlags.Undefined) !== 0) return { raw, name: 'undefined', undefined: true };
315
+ if ((flags & typescript.TypeFlags.Null) !== 0) return { raw, name: 'null', nullable: true };
316
+ if ((flags & typescript.TypeFlags.Any) !== 0) return { raw, name: 'any' };
317
+ if ((flags & typescript.TypeFlags.Unknown) !== 0) return { raw, name: 'unknown' };
318
+
319
+ return undefined;
320
+ }
321
+
376
322
  // ── Module resolution ────────────────────────
377
323
 
378
324
  const TS_KEYWORDS = new Set([
@@ -473,9 +419,18 @@ function extractClassMethods(
473
419
  if (skip.has(name)) continue;
474
420
 
475
421
  const params = member.parameters.map((p) => parsedParam(p, source, checker));
476
- const returnType = member.type ? parseTypeNode(member.type, source, checker) : undefined;
422
+ // An op that annotates nothing still HAS a return type, and the checker holds it — so it
423
+ // carries an output on the card, a cardinality, and the codecs a caller reads it back
424
+ // through. Measured 2026-09-18: 18 of the 42 unannotated ops here answer a declared entity,
425
+ // and every one of them handed a caller the encoded row its type called a `Date`.
426
+ const answered = checker ? answeredBy(checker.getSignatureFromDeclaration(member), checker) : undefined;
427
+ const returnType = member.type ? parseTypeNode(member.type, source, checker)
428
+ : answered && checker ? parseCheckedType(answered, checker.typeToString(answered), checker)
429
+ : undefined;
430
+ const refused = answered && checker ? notData(answered, name, checker) : undefined;
477
431
  results.push({
478
432
  name, params, returnType,
433
+ ...(refused ? { notData: refused } : {}),
479
434
  description: docSentenceOf(member, source),
480
435
  });
481
436
  }
@@ -538,6 +493,7 @@ function inheritedFromBase(
538
493
  if (!signature) continue;
539
494
 
540
495
  const returned = signature.getReturnType();
496
+ const refusedOutput = notData(checker.getAwaitedType(returned) ?? returned, property.name, checker);
541
497
  const sentence = typescript.displayPartsToString(property.getDocumentationComment(checker)).trim();
542
498
  results.push({
543
499
  name: property.name,
@@ -550,6 +506,7 @@ function inheritedFromBase(
550
506
  };
551
507
  }),
552
508
  returnType: parseCheckedType(returned, checker.typeToString(returned), checker),
509
+ ...(refusedOutput ? { notData: refusedOutput } : {}),
553
510
  ...(sentence ? { description: sentence.split(/(?<=\.)\s/)[0] } : {}),
554
511
  });
555
512
  }
@@ -599,35 +556,6 @@ function parseInheritedMethods(
599
556
  return [];
600
557
  }
601
558
 
602
- // ── Class finding ────────────────────────────
603
-
604
- /** Find the default exported class in a source file. */
605
- function findDefaultClass(source: ts.SourceFile): ts.ClassDeclaration | undefined {
606
- const ts = getTS();
607
- for (const stmt of source.statements) {
608
- // export default class Foo { ... }
609
- if (ts.isClassDeclaration(stmt) && stmt.modifiers?.some(
610
- (m) => m.kind === ts.SyntaxKind.ExportKeyword,
611
- ) && stmt.modifiers?.some(
612
- (m) => m.kind === ts.SyntaxKind.DefaultKeyword,
613
- )) {
614
- return stmt;
615
- }
616
- }
617
-
618
- // export default Foo (separate statement) — find the class it points to
619
- for (const stmt of source.statements) {
620
- if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
621
- const name = stmt.expression.text;
622
- for (const s of source.statements) {
623
- if (ts.isClassDeclaration(s) && s.name?.text === name) return s;
624
- }
625
- }
626
- }
627
-
628
- return undefined;
629
- }
630
-
631
559
  /** What a handler file yielded — its methods, AND what the pass could not open. */
632
560
  export interface HandlerParse {
633
561
  methods: Signature[];
@@ -86,6 +86,17 @@ function codeIn(typescript: typeof ts, node: ts.NewExpression): ErrorCode | unde
86
86
  * on `PostRepository`, which is the same node a refusal inside it was charged to. No name
87
87
  * matching, and no guess about what a dependency holds.
88
88
  */
89
+ /** The set held under a key, created the first time something lands in it. */
90
+ function into<T>(held: Map<string, Set<T>>, key: string): Set<T> {
91
+ const already = held.get(key);
92
+ if (already) return already;
93
+
94
+ const made = new Set<T>();
95
+ held.set(key, made);
96
+
97
+ return made;
98
+ }
99
+
89
100
  export function refusalsIn(typescript: typeof ts, program: ts.Program, isOperation: (name: string) => boolean): Refusals {
90
101
  const checker = program.getTypeChecker();
91
102
  const sites: Site[] = [];
@@ -106,7 +117,7 @@ export function refusalsIn(typescript: typeof ts, program: ts.Program, isOperati
106
117
  const callee = calleeOf(typescript, checker, node);
107
118
  const caller = callee ? holderOf(typescript, node) : undefined;
108
119
  if (callee && caller && callee !== caller) {
109
- (callers.get(callee) ?? callers.set(callee, new Set()).get(callee)!).add(caller);
120
+ into(callers, callee).add(caller);
110
121
  }
111
122
  }
112
123
 
@@ -154,7 +165,7 @@ function reached(
154
165
 
155
166
  while (queue.length > 0) {
156
167
  const at = queue.shift()!;
157
- if (isOperation(at)) (found.get(at) ?? found.set(at, new Set()).get(at)!).add(site.code);
168
+ if (isOperation(at)) into(found, at).add(site.code);
158
169
  for (const caller of callers.get(at) ?? []) {
159
170
  if (walked.has(caller)) continue;
160
171
  walked.add(caller);