@fougere/compiler 0.11.0-alpha.0 → 0.12.0-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.
- package/dist/boot.d.ts +8 -3
- package/dist/boot.d.ts.map +1 -1
- package/dist/boot.js +14 -4
- package/dist/boot.js.map +1 -1
- package/dist/scan/Implemented.d.ts +14 -0
- package/dist/scan/Implemented.d.ts.map +1 -0
- package/dist/scan/Implemented.js +34 -0
- package/dist/scan/Implemented.js.map +1 -0
- package/dist/scan/TypeProgram.d.ts +21 -0
- package/dist/scan/TypeProgram.d.ts.map +1 -0
- package/dist/scan/TypeProgram.js +177 -0
- package/dist/scan/TypeProgram.js.map +1 -0
- package/dist/scan/emit.d.ts.map +1 -1
- package/dist/scan/emit.js +37 -28
- package/dist/scan/emit.js.map +1 -1
- package/dist/scan/handler-parser.d.ts +0 -3
- package/dist/scan/handler-parser.d.ts.map +1 -1
- package/dist/scan/handler-parser.js +111 -278
- package/dist/scan/handler-parser.js.map +1 -1
- package/dist/scan/refusals.d.ts +0 -7
- package/dist/scan/refusals.d.ts.map +1 -1
- package/dist/scan/refusals.js +11 -2
- package/dist/scan/refusals.js.map +1 -1
- package/dist/scan/scanner.d.ts.map +1 -1
- package/dist/scan/scanner.js +118 -82
- package/dist/scan/scanner.js.map +1 -1
- package/package.json +6 -6
- package/src/boot.ts +23 -7
- package/src/scan/Implemented.ts +41 -0
- package/src/scan/TypeProgram.ts +199 -0
- package/src/scan/emit.ts +32 -20
- package/src/scan/handler-parser.ts +122 -306
- package/src/scan/refusals.ts +13 -2
- package/src/scan/scanner.ts +158 -94
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** The TypeScript program the scan reads through, and the mutable state it keeps. */
|
|
2
|
+
import type ts from '@typescript/typescript6';
|
|
3
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
4
|
+
import { join, dirname, resolve as resolvePath } from 'node:path';
|
|
5
|
+
|
|
6
|
+
/** Lazy-loaded TypeScript module — avoids bundling the 9MB compiler. */
|
|
7
|
+
let _ts: typeof ts | undefined;
|
|
8
|
+
export async function loadTS(): Promise<typeof ts> {
|
|
9
|
+
if (!_ts) _ts = (await import('@typescript/typescript6')).default;
|
|
10
|
+
return _ts;
|
|
11
|
+
}
|
|
12
|
+
export function getTS(): typeof ts {
|
|
13
|
+
if (!_ts) throw new Error('TypeScript not loaded — call an async parse function first');
|
|
14
|
+
return _ts;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TypeProject {
|
|
18
|
+
roots: Set<string>;
|
|
19
|
+
options: ts.CompilerOptions;
|
|
20
|
+
program: ts.Program;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One checked program per project/configuration during a scan. */
|
|
24
|
+
const typeProjects = new Map<string, TypeProject>();
|
|
25
|
+
const compilerProjects = new Map<string, { key: string; roots: string[]; options: ts.CompilerOptions }>();
|
|
26
|
+
|
|
27
|
+
/** What survives a run. */
|
|
28
|
+
const sourceFiles = new Map<string, { mtime: number; file: ts.SourceFile }>();
|
|
29
|
+
const retained = new Map<string, { host: ts.CompilerHost; program: ts.Program }>();
|
|
30
|
+
|
|
31
|
+
export function resetTypePrograms(): void {
|
|
32
|
+
typeProjects.clear();
|
|
33
|
+
compilerProjects.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function keptHost(key: string, options: ts.CompilerOptions): ts.CompilerHost {
|
|
37
|
+
const cached = retained.get(key);
|
|
38
|
+
if (cached) return cached.host;
|
|
39
|
+
const typescript = getTS();
|
|
40
|
+
const base = typescript.createCompilerHost(options);
|
|
41
|
+
const host: ts.CompilerHost = {
|
|
42
|
+
...base,
|
|
43
|
+
getSourceFile(fileName, languageVersion, onError, shouldCreate) {
|
|
44
|
+
const path = resolvePath(fileName);
|
|
45
|
+
const mtime = statSync(path, { throwIfNoEntry: false })?.mtimeMs ?? -1;
|
|
46
|
+
const cached = sourceFiles.get(path);
|
|
47
|
+
if (cached && cached.mtime === mtime) return cached.file;
|
|
48
|
+
const file = base.getSourceFile(fileName, languageVersion, onError, shouldCreate);
|
|
49
|
+
if (file && mtime >= 0) sourceFiles.set(path, { mtime, file });
|
|
50
|
+
return file;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
retained.set(key, { host, program: undefined as unknown as ts.Program });
|
|
54
|
+
return host;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function builtProgram(key: string, roots: readonly string[], options: ts.CompilerOptions): ts.Program {
|
|
58
|
+
const typescript = getTS();
|
|
59
|
+
const host = keptHost(key, options);
|
|
60
|
+
const program = typescript.createProgram({
|
|
61
|
+
rootNames: [...roots], options, host, oldProgram: retained.get(key)?.program,
|
|
62
|
+
});
|
|
63
|
+
retained.set(key, { host, program });
|
|
64
|
+
return program;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compilerProjectOf(filePath: string, projectRoot?: string): { key: string; roots: string[]; options: ts.CompilerOptions } {
|
|
68
|
+
const typescript = getTS();
|
|
69
|
+
const absolute = resolvePath(filePath);
|
|
70
|
+
const configPath = typescript.findConfigFile(dirname(absolute), typescript.sys.fileExists);
|
|
71
|
+
|
|
72
|
+
if (configPath) {
|
|
73
|
+
const key = `${configPath}:${projectRoot ?? ''}`;
|
|
74
|
+
const cached = compilerProjects.get(key);
|
|
75
|
+
if (cached) return cached;
|
|
76
|
+
|
|
77
|
+
const read = typescript.readConfigFile(configPath, typescript.sys.readFile);
|
|
78
|
+
if (read.error) throw new Error(typescript.flattenDiagnosticMessageText(read.error.messageText, '\n'));
|
|
79
|
+
const parsed = typescript.parseJsonConfigFileContent(read.config, typescript.sys, dirname(configPath));
|
|
80
|
+
const configured = {
|
|
81
|
+
key,
|
|
82
|
+
// Compiler options belong to the project; its entire include glob does not belong
|
|
83
|
+
// to this scan. Each declaration inspected below becomes a root and TypeScript
|
|
84
|
+
// follows its imports. Seeding the monorepo here made a one-file scan compile it all.
|
|
85
|
+
roots: [],
|
|
86
|
+
// Handlers may be authored or emitted as JavaScript. They still need to belong to
|
|
87
|
+
// the checked program so constructor parsing does not fail on the first cold scan.
|
|
88
|
+
options: { ...parsed.options, allowJs: true, noEmit: true },
|
|
89
|
+
};
|
|
90
|
+
compilerProjects.set(key, configured);
|
|
91
|
+
return configured;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const key = projectRoot ?? dirname(absolute);
|
|
95
|
+
const cached = compilerProjects.get(key);
|
|
96
|
+
if (cached) return cached;
|
|
97
|
+
const configured = {
|
|
98
|
+
key,
|
|
99
|
+
roots: [],
|
|
100
|
+
options: {
|
|
101
|
+
target: typescript.ScriptTarget.ES2022,
|
|
102
|
+
module: typescript.ModuleKind.Node16,
|
|
103
|
+
moduleResolution: typescript.ModuleResolutionKind.Node16,
|
|
104
|
+
strict: true,
|
|
105
|
+
skipLibCheck: true,
|
|
106
|
+
allowJs: true,
|
|
107
|
+
noEmit: true,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
compilerProjects.set(key, configured);
|
|
111
|
+
return configured;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Declare every file a run will read, so one program covers it. */
|
|
115
|
+
export async function seedTypeProgram(filePaths: readonly string[], projectRoot?: string): Promise<void> {
|
|
116
|
+
const typescript = await loadTS();
|
|
117
|
+
const grouped = new Map<string, { options: ts.CompilerOptions; paths: string[] }>();
|
|
118
|
+
|
|
119
|
+
for (const filePath of filePaths) {
|
|
120
|
+
const absolute = resolvePath(filePath);
|
|
121
|
+
const typescript = getTS();
|
|
122
|
+
const configured = compilerProjectOf(absolute, projectRoot);
|
|
123
|
+
const group = grouped.get(configured.key) ?? { options: configured.options, paths: [] };
|
|
124
|
+
group.paths.push(absolute);
|
|
125
|
+
grouped.set(configured.key, group);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const [key, { options, paths }] of grouped) {
|
|
129
|
+
const roots = new Set(typeProjects.get(key)?.roots ?? []);
|
|
130
|
+
for (const path of paths) roots.add(path);
|
|
131
|
+
typeProjects.set(key, { roots, options, program: builtProgram(key, [...roots], options) });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The program a file belongs to, built once and widened as more files are asked for. */
|
|
136
|
+
export function projectOf(filePath: string, projectRoot?: string): { program: ts.Program; absolute: string } {
|
|
137
|
+
const absolute = resolvePath(filePath);
|
|
138
|
+
const configured = compilerProjectOf(absolute, projectRoot);
|
|
139
|
+
let project = typeProjects.get(configured.key);
|
|
140
|
+
|
|
141
|
+
if (!project) {
|
|
142
|
+
// `path.resolve` is variadic, so handing it directly to `map` also passed the
|
|
143
|
+
// index and the whole roots array as path segments. A fixture without a warm scan
|
|
144
|
+
// cache exposed that first-run-only failure.
|
|
145
|
+
const roots = new Set(configured.roots.map((root) => resolvePath(root)));
|
|
146
|
+
roots.add(absolute);
|
|
147
|
+
const program = builtProgram(configured.key, [...roots], configured.options);
|
|
148
|
+
project = { roots, options: configured.options, program };
|
|
149
|
+
typeProjects.set(configured.key, project);
|
|
150
|
+
} else if (!project.roots.has(absolute)) {
|
|
151
|
+
project.roots.add(absolute);
|
|
152
|
+
project.program = builtProgram(configured.key, [...project.roots], project.options);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { program: project.program, absolute };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function checkedSourceOf(filePath: string, projectRoot?: string): { source: ts.SourceFile; checker: ts.TypeChecker } {
|
|
159
|
+
const { program, absolute } = projectOf(filePath, projectRoot);
|
|
160
|
+
const source = program.getSourceFile(absolute);
|
|
161
|
+
if (!source) throw new Error(`TypeScript did not include '${absolute}' in its program.`);
|
|
162
|
+
|
|
163
|
+
return { source, checker: program.getTypeChecker() };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A file, opened. Five places read and parsed one, each spelling the same two calls. */
|
|
167
|
+
export function sourceOf(filePath: string): ts.SourceFile {
|
|
168
|
+
const ts = getTS();
|
|
169
|
+
return ts.createSourceFile(filePath, readFileSync(filePath, 'utf-8'), ts.ScriptTarget.Latest, true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Class finding ────────────────────────────
|
|
173
|
+
|
|
174
|
+
/** Find the default exported class in a source file. */
|
|
175
|
+
export function findDefaultClass(source: ts.SourceFile): ts.ClassDeclaration | undefined {
|
|
176
|
+
const ts = getTS();
|
|
177
|
+
for (const stmt of source.statements) {
|
|
178
|
+
// export default class Foo { ... }
|
|
179
|
+
if (ts.isClassDeclaration(stmt) && stmt.modifiers?.some(
|
|
180
|
+
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
|
181
|
+
) && stmt.modifiers?.some(
|
|
182
|
+
(m) => m.kind === ts.SyntaxKind.DefaultKeyword,
|
|
183
|
+
)) {
|
|
184
|
+
return stmt;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// export default Foo (separate statement) — find the class it points to
|
|
189
|
+
for (const stmt of source.statements) {
|
|
190
|
+
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals && ts.isIdentifier(stmt.expression)) {
|
|
191
|
+
const name = stmt.expression.text;
|
|
192
|
+
for (const s of source.statements) {
|
|
193
|
+
if (ts.isClassDeclaration(s) && s.name?.text === name) return s;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
package/src/scan/emit.ts
CHANGED
|
@@ -86,13 +86,16 @@ function collectorOf(c: CollectorEntry, imports: Imports): string {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
function providerOf(p: ProviderEntry, imports: Imports): string {
|
|
89
|
+
// `kept` travels like every other member: a provider that loses it on disk is registered
|
|
90
|
+
// transient, so the same code would answer differently depending on how it was booted.
|
|
89
91
|
return `{ name: ${lit(nameOf(p))}, ctor: ${imports.aliasOf(p.ctor as Live)}, `
|
|
90
|
-
+ `deps: ${lit(p.deps)}, filePath: ${lit(p.filePath)}
|
|
92
|
+
+ `deps: ${lit(p.deps)}, filePath: ${lit(p.filePath)}`
|
|
93
|
+
+ `${p.kept ? ', kept: true' : ''} }`;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
function middlewareOf(m: MiddlewareEntry, imports: Imports): string {
|
|
94
97
|
return `{ name: ${lit(m.name)}, ctor: ${imports.aliasOf(m.ctor as Live)}, `
|
|
95
|
-
+ `
|
|
98
|
+
+ `deps: ${lit(m.deps)}, filePath: ${lit(m.filePath)} }`;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
function seedOf(s: SeedEntry, imports: Imports): string {
|
|
@@ -125,25 +128,11 @@ export function emitScan(result: ScanResult, options: EmitOptions): string {
|
|
|
125
128
|
const imports = new Imports(options.outFile);
|
|
126
129
|
const core = options.core ?? '@fougere/core';
|
|
127
130
|
|
|
128
|
-
|
|
129
|
-
// them then finds it in the table instead of being imported a second time.
|
|
130
|
-
for (const f of result.fronds) {
|
|
131
|
-
for (const e of f.entities) {
|
|
132
|
-
imports.default(e.entityClass as Live, e.filePath);
|
|
133
|
-
const name = (e.entityClass as { name?: string }).name;
|
|
134
|
-
if (name) imports.byClassName.set(name, e.entityClass as Live);
|
|
135
|
-
}
|
|
136
|
-
for (const p of f.providers) imports.default(p.ctor as Live, p.filePath);
|
|
137
|
-
for (const h of f.handlers) imports.default(h.ctor as Live, h.filePath);
|
|
138
|
-
for (const p of f.presenters) imports.default(p.ctor as Live, p.filePath);
|
|
139
|
-
for (const c of f.collectors) imports.default(c.ctor as Live, c.filePath);
|
|
140
|
-
for (const m of f.middlewares) imports.default(m.ctor as Live, m.filePath);
|
|
141
|
-
for (const s of f.seeds) if (typeof s.data === 'function') imports.default(s.data as Live, s.filePath);
|
|
142
|
-
}
|
|
131
|
+
collectImports(result, imports);
|
|
143
132
|
|
|
144
|
-
const fronds = result.fronds.map((
|
|
145
|
-
// `cause` is dropped: it is an Error, and the build that produced this already reported
|
|
146
|
-
//
|
|
133
|
+
const fronds = result.fronds.map((one) => frondOf(one, imports)).join(',\n');
|
|
134
|
+
// `cause` is dropped: it is an Error, and the build that produced this already reported it.
|
|
135
|
+
// What a boot logs is the message, and that travels.
|
|
147
136
|
const diagnostics = result.diagnostics.map(({ cause: _cause, ...rest }) => lit(rest));
|
|
148
137
|
|
|
149
138
|
// The annotation is a convenience for a file a human's tsc will read; it is never what
|
|
@@ -166,3 +155,26 @@ ${fronds}
|
|
|
166
155
|
};
|
|
167
156
|
`;
|
|
168
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Every class the descriptor names by a file, imported first: a schema slot holding one of them
|
|
161
|
+
* then finds it in the table instead of being imported a second time.
|
|
162
|
+
*/
|
|
163
|
+
function collectImports(result: ScanResult, imports: Imports): void {
|
|
164
|
+
for (const frond of result.fronds) {
|
|
165
|
+
for (const entity of frond.entities) {
|
|
166
|
+
imports.default(entity.entityClass as Live, entity.filePath);
|
|
167
|
+
const name = (entity.entityClass as { name?: string }).name;
|
|
168
|
+
if (name) imports.byClassName.set(name, entity.entityClass as Live);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const one of frond.providers) imports.default(one.ctor as Live, one.filePath);
|
|
172
|
+
for (const one of frond.handlers) imports.default(one.ctor as Live, one.filePath);
|
|
173
|
+
for (const one of frond.presenters) imports.default(one.ctor as Live, one.filePath);
|
|
174
|
+
for (const one of frond.collectors) imports.default(one.ctor as Live, one.filePath);
|
|
175
|
+
for (const one of frond.middlewares) imports.default(one.ctor as Live, one.filePath);
|
|
176
|
+
for (const seed of frond.seeds) {
|
|
177
|
+
if (typeof seed.data === 'function') imports.default(seed.data as Live, seed.filePath);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|