@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.
package/src/boot.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Logger, applyConfig, createApp, type App, type CreateAppOptions, type Extension, type FougereConfig, type Transport } from '@fougere/core';
2
- import { loadConfig } from '@fougere/core/node';
2
+ import { loadConfig, remotesOf, statedModules } from '@fougere/core/node';
3
3
  import { scanProject } from './scan/scanner.js';
4
4
 
5
5
  import type { Container } from '@fougere/container';
@@ -9,9 +9,14 @@ interface BootOptions {
9
9
  root?: string;
10
10
  /** Override config (merged with fougere.config.ts). */
11
11
  config?: Partial<FougereConfig>;
12
- /** Container factory. Required. */
13
- createContainer: () => Container;
14
- /** Only boot these fronds (by name). Absent = all. */
12
+ /** The container to resolve through. Absent, core builds its own. */
13
+ createContainer?: () => Container;
14
+ /**
15
+ * Boot only these fronds, by name — absent, every one found. Not `fronds:`, which says what
16
+ * an app is MADE of: this narrows the same list to what one process carries.
17
+ */
18
+ only?: string[];
19
+ /** @deprecated The name `only:` now carries. */
15
20
  fronds?: string[];
16
21
  /**
17
22
  * Remote fronds — label → address. A declared remote wins over local
@@ -64,16 +69,27 @@ export async function boot(options: BootOptions): Promise<App> {
64
69
  log.info('database initialized');
65
70
  }
66
71
 
72
+ // What the config names by module — imported here, because core resolves no specifier.
73
+ const stated = await statedModules(config.fronds);
74
+
67
75
  log.debug('creating app (scan + container)');
68
76
  const app = await createApp({
77
+ ...(stated.fronds.length > 0 ? { fronds: stated.fronds } : {}),
69
78
  // boot() lives on the Node entry, so boot() is what reads the disk. `createApp` is
70
79
  // handed the answer and reaches for nothing.
71
- scan: await scanProject(root, options.fronds, config.conventions),
80
+ scan: await scanProject(root, options.only ?? options.fronds, config.conventions),
72
81
  createContainer: options.createContainer,
73
82
  storageFactory: dbSetup?.storageFactory,
74
83
  db: dbSetup?.db,
75
84
  auth: config.auth,
76
- remotes: options.remotes,
85
+ // The tree's string leaves say the same thing `remotes:` does, and this boot read
86
+ // neither of them from the file until now — a project stating its topology got a
87
+ // process that believed every frond was local.
88
+ remotes: options.remotes ?? remotesOf(config),
89
+ // Who inherits code from whom — handed over whole, because a refusal names where an
90
+ // entry sits in the tree.
91
+ under: config.fronds,
92
+ narrowed: (options.only ?? options.fronds) !== undefined,
77
93
  ports: config.ports,
78
94
  // Read from the config for the same reason `ports` is, one line up: it is a fact the
79
95
  // project states, not one the caller passes. Absent here, `serveRest` and
@@ -87,7 +103,7 @@ export async function boot(options: BootOptions): Promise<App> {
87
103
  // Its own gesture, handed over whole. The ORDER — tables, then rows, then whatever this
88
104
  // host takes on — is `createApp`'s, and was written out by four hosts before.
89
105
  migrate: dbSetup?.migrate,
90
- extensions: options.extensions ?? [],
106
+ extensions: [...stated.extensions, ...(options.extensions ?? [])],
91
107
  onEmit: options.onEmit,
92
108
  remoteTransport: options.remoteTransport,
93
109
  });
@@ -0,0 +1,41 @@
1
+ /** What a scanned class declares it implements. */
2
+ import { loadTS, checkedSourceOf, findDefaultClass } from './TypeProgram.js';
3
+
4
+ /**
5
+ * What a scanned class says it IMPLEMENTS, and whether each name is a class.
6
+ *
7
+ * A class is the interesting case: `implements` is erased, so `Object.getPrototypeOf(ctor).name`
8
+ * is empty and the boot binds no port. An interface is the ordinary one — and one of them,
9
+ * `AsyncDisposable`, is how a provider says its scope keeps it and closes it.
10
+ */
11
+ export async function parseImplements(filePath: string, projectRoot?: string): Promise<Implemented[]> {
12
+ const ts = await loadTS();
13
+ const { source, checker } = checkedSourceOf(filePath, projectRoot);
14
+ const cls = findDefaultClass(source);
15
+ if (!cls?.heritageClauses) return [];
16
+
17
+ const found: Implemented[] = [];
18
+ for (const clause of cls.heritageClauses) {
19
+ if (clause.token !== ts.SyntaxKind.ImplementsKeyword) continue;
20
+
21
+ for (const base of clause.types) {
22
+ if (!ts.isIdentifier(base.expression)) continue;
23
+
24
+ let symbol = checker.getSymbolAtLocation(base.expression);
25
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol);
26
+
27
+ found.push({
28
+ name: base.expression.text,
29
+ isClass: symbol?.declarations?.some((one) => ts.isClassDeclaration(one)) ?? false,
30
+ });
31
+ }
32
+ }
33
+
34
+ return found;
35
+ }
36
+
37
+ /** One `implements` clause, and what the checker says its name is. */
38
+ export interface Implemented {
39
+ name: string;
40
+ isClass: boolean;
41
+ }
@@ -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
- + `scope: ${lit(m.scope)}, deps: ${lit(m.deps)}, filePath: ${lit(m.filePath)} }`;
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
- // Every class the descriptor names by a file comes first: a schema slot holding one of
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((f) => frondOf(f, imports)).join(',\n');
145
- // `cause` is dropped: it is an Error, and the build that produced this already reported
146
- // it. What a boot logs is the message, and that travels.
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
+ }