@fougere/compiler 0.10.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
package/src/scan/scanner.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { DEFAULT_CONVENTIONS, frondDirsOf, frondPackage, providerDirsOf, resolve
|
|
|
2
2
|
import { Fronds, awaitKeyOf, cardinalityOf, computeBindingPlan, emitKeyOf, getPresenterFields, outputOf, ownedBy, repositoryKeyOf, storageKeyOf, targetOf, type CollectorEntry, type EntityEntry, type FrondDescriptor, type HandlerEntry, type MiddlewareEntry, type OperationContract, type OperationsMap, type PresenterEntry, type ProviderEntry, type SeedEntry, type TypeRef, viewsOf, type ExtensionEntry } from '@fougere/core/descriptor';
|
|
3
3
|
import { getModuleLoader, loadFrondConfig } from '@fougere/core/node';
|
|
4
4
|
import type { FrondConfig, ErrorCode } from '@fougere/core';
|
|
5
|
+
import type { Signature } from '@fougere/core/descriptor';
|
|
5
6
|
import { readdir, readFile } from 'node:fs/promises';
|
|
6
7
|
import { existsSync, type Dirent } from 'node:fs';
|
|
7
8
|
import { join, dirname, basename, resolve as resolvePath } from 'node:path';
|
|
@@ -13,9 +14,9 @@ import {
|
|
|
13
14
|
parseRefusals,
|
|
14
15
|
parsePresenterMethods,
|
|
15
16
|
parseConstructorParams,
|
|
16
|
-
resetTypePrograms,
|
|
17
|
-
seedTypeProgram,
|
|
18
17
|
} from './handler-parser.js';
|
|
18
|
+
import { parseImplements } from './Implemented.js';
|
|
19
|
+
import { resetTypePrograms, seedTypeProgram } from './TypeProgram.js';
|
|
19
20
|
|
|
20
21
|
import { lowerFirst } from '@fougere/schema';
|
|
21
22
|
|
|
@@ -96,7 +97,7 @@ async function loadClass(filePath: string): Promise<ProviderEntry['ctor']> {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
function isEntityClass(value: unknown): value is SchemaView {
|
|
99
|
-
return typeof value === 'function' && 'getFields' in (value as
|
|
100
|
+
return typeof value === 'function' && 'getFields' in (value as object);
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
// Workspace
|
|
@@ -176,6 +177,9 @@ function tupleMembers(raw: string): string[] {
|
|
|
176
177
|
const ctorParamsOf = (filePath: string) =>
|
|
177
178
|
parseConstructorParams(filePath);
|
|
178
179
|
|
|
180
|
+
const implementsOf = (filePath: string) =>
|
|
181
|
+
parseImplements(filePath);
|
|
182
|
+
|
|
179
183
|
const presenterMethodsOf = (filePath: string) =>
|
|
180
184
|
parsePresenterMethods(filePath);
|
|
181
185
|
|
|
@@ -190,6 +194,28 @@ async function toProvider(filePath: string): Promise<ProviderEntry> {
|
|
|
190
194
|
const params = await ctorParamsOf(filePath);
|
|
191
195
|
const deps = params.map((p) => depKeyOf(p.type));
|
|
192
196
|
|
|
197
|
+
const stated = await implementsOf(filePath);
|
|
198
|
+
|
|
199
|
+
// A port is answered by EXTENDING it: the boot reads the prototype chain, and `implements`
|
|
200
|
+
// leaves none. Said here rather than refused, because implementing a class is legal — only
|
|
201
|
+
// the author knows whether a port was meant. Warned, so `createApp` prints it too.
|
|
202
|
+
for (const { name: base } of stated.filter((one) => one.isClass)) {
|
|
203
|
+
record({
|
|
204
|
+
severity: 'warning',
|
|
205
|
+
code: 'port-implemented-not-extended',
|
|
206
|
+
filePath,
|
|
207
|
+
subject: name,
|
|
208
|
+
message: `${name} implements the class ${base}. A port is answered by EXTENDING it — `
|
|
209
|
+
+ `\`implements\` leaves nothing at runtime, so the boot binds no port and the first `
|
|
210
|
+
+ `dependency on ${base} answers '${base}' is not registered. Write `
|
|
211
|
+
+ `\`class ${name} extends ${base}\`, or ${base} states an interface if its shape is all you wanted.`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `implements AsyncDisposable` — the language's own marker, which `App` already answers. It
|
|
216
|
+
// says two things at once: there is ONE of me in this frond's scope, and that scope closes me.
|
|
217
|
+
const kept = stated.some((one) => one.name === 'AsyncDisposable');
|
|
218
|
+
|
|
193
219
|
// A repository inherits its constructor from `Repository(…)`, so the file declares none
|
|
194
220
|
// and the scan reads no parameter. The mixin knows what it was built for and says so at
|
|
195
221
|
// runtime — same escape as `Crud.__ops`, and the same reason: what a prefab fabricates,
|
|
@@ -209,7 +235,7 @@ async function toProvider(filePath: string): Promise<ProviderEntry> {
|
|
|
209
235
|
// `name` beside `ctor`, and it IS the registration key — what `depKeyOf` returns, since
|
|
210
236
|
// it reads the type as written. It used to be asked of `ctor.name` at boot, which held
|
|
211
237
|
// until a bundler lowered a static field and renamed the declaration doing it.
|
|
212
|
-
return { name, ctor, deps, filePath };
|
|
238
|
+
return { name, ctor, deps, filePath, ...(kept ? { kept: true as const } : {}) };
|
|
213
239
|
}
|
|
214
240
|
|
|
215
241
|
/**
|
|
@@ -264,8 +290,9 @@ function resolveSchema(type: TypeRef, moduleExports: Record<string, unknown>): S
|
|
|
264
290
|
// `Partial<X>` in a signature IS the patch declaration (Crud.update) —
|
|
265
291
|
// project it onto the schema view instead of dropping the wrapper, so
|
|
266
292
|
// the facade validates in patch mode (absent field → untouched).
|
|
267
|
-
|
|
268
|
-
|
|
293
|
+
const narrowing = (resolved as { partial?: () => SchemaView }).partial;
|
|
294
|
+
if (type.name === 'Partial' && typeof narrowing === 'function') {
|
|
295
|
+
return narrowing.call(resolved);
|
|
269
296
|
}
|
|
270
297
|
return resolved as unknown as SchemaView;
|
|
271
298
|
}
|
|
@@ -278,6 +305,83 @@ function resolveSchema(type: TypeRef, moduleExports: Record<string, unknown>): S
|
|
|
278
305
|
return undefined;
|
|
279
306
|
}
|
|
280
307
|
|
|
308
|
+
/**
|
|
309
|
+
* A convention may omit a declaration only when it has one answer.
|
|
310
|
+
*
|
|
311
|
+
* Only values the caller supplies through the BODY are candidates: a schema-typed collector,
|
|
312
|
+
* fact or context parameter is not input merely because it names an entity. Reading them all
|
|
313
|
+
* ignored provenance and took the first schema it met, so swapping two parameters silently
|
|
314
|
+
* changed the contract the façade validated the request against.
|
|
315
|
+
*/
|
|
316
|
+
function inputOf(
|
|
317
|
+
method: Signature,
|
|
318
|
+
handlerName: string,
|
|
319
|
+
filePath: string,
|
|
320
|
+
collectorTypeNames: Set<string>,
|
|
321
|
+
moduleExports: Record<string, unknown>,
|
|
322
|
+
stated: boolean,
|
|
323
|
+
): SchemaView | undefined {
|
|
324
|
+
const binding = computeBindingPlan(method.params, collectorTypeNames);
|
|
325
|
+
const candidates = method.params.flatMap((param, index) => {
|
|
326
|
+
if (binding[index]?.source.kind !== 'input') return [];
|
|
327
|
+
const schema = resolveSchema(param.type, moduleExports);
|
|
328
|
+
|
|
329
|
+
return schema ? [{ param, schema }] : [];
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
if (candidates.length === 1) return candidates[0]!.schema;
|
|
333
|
+
if (candidates.length < 2 || stated) return undefined;
|
|
334
|
+
|
|
335
|
+
const subject = `${handlerName}.${method.name}`;
|
|
336
|
+
record({
|
|
337
|
+
severity: 'blocking',
|
|
338
|
+
code: 'input-contract-ambiguous',
|
|
339
|
+
filePath,
|
|
340
|
+
subject,
|
|
341
|
+
message: `Cannot infer the input contract for ${subject}: ${candidates.length} entity `
|
|
342
|
+
+ `candidates — ${candidates.map(({ param }) => `${param.name}: ${param.type.raw}`).join('; ')}. `
|
|
343
|
+
+ `Declare operations.${method.name}.input in frond.config.ts.`,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** What one method of a handler is read against, while its contract is built. */
|
|
350
|
+
interface Reading {
|
|
351
|
+
handlerName: string;
|
|
352
|
+
filePath: string;
|
|
353
|
+
moduleExports: Record<string, unknown>;
|
|
354
|
+
collectorTypeNames: Set<string>;
|
|
355
|
+
refused: ErrorCode[] | undefined;
|
|
356
|
+
stated: boolean;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The contract carries the description; `signature` is the raw material it was read from.
|
|
361
|
+
* Leaving it only on the signature meant every consumer had to look one level down, and only
|
|
362
|
+
* the façade did.
|
|
363
|
+
*/
|
|
364
|
+
function contractOf(method: Signature, read: Reading): OperationContract {
|
|
365
|
+
const meta: OperationContract = {
|
|
366
|
+
signature: method,
|
|
367
|
+
...(method.description && { description: method.description }),
|
|
368
|
+
...(read.refused?.length ? { errors: read.refused } : {}),
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const input = inputOf(
|
|
372
|
+
method, read.handlerName, read.filePath, read.collectorTypeNames, read.moduleExports, read.stated,
|
|
373
|
+
);
|
|
374
|
+
if (input) meta.input = input;
|
|
375
|
+
|
|
376
|
+
if (method.returnType) {
|
|
377
|
+
meta.output = resolveSchema(method.returnType, read.moduleExports);
|
|
378
|
+
// `output` is the shape of one row; this says how many rows come back.
|
|
379
|
+
meta.cardinality = cardinalityOf(method.returnType);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return meta;
|
|
383
|
+
}
|
|
384
|
+
|
|
281
385
|
/** Parse ALL method signatures for unified binding. */
|
|
282
386
|
async function inferOperations(
|
|
283
387
|
filePath: string,
|
|
@@ -334,53 +438,11 @@ async function inferOperations(
|
|
|
334
438
|
}
|
|
335
439
|
|
|
336
440
|
for (const method of parsed.methods) {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
signature: method,
|
|
343
|
-
...(method.description && { description: method.description }),
|
|
344
|
-
...(refused?.length ? { errors: refused } : {}),
|
|
345
|
-
};
|
|
346
|
-
|
|
347
|
-
// A convention may omit a declaration only when it has one answer. Only values the
|
|
348
|
-
// caller supplies through the body are candidates: a schema-typed collector, fact or
|
|
349
|
-
// context parameter is not input merely because it names an entity. The old loop
|
|
350
|
-
// ignored provenance and assigned the first schema it met, so swapping two parameters
|
|
351
|
-
// silently changed the contract the façade used to validate the request body.
|
|
352
|
-
const binding = computeBindingPlan(method.params, collectorTypeNames);
|
|
353
|
-
const candidates = method.params.flatMap((param, index) => {
|
|
354
|
-
if (binding[index]?.source.kind !== 'input') return [];
|
|
355
|
-
const schema = resolveSchema(param.type, moduleExports);
|
|
356
|
-
return schema ? [{ param, schema }] : [];
|
|
357
|
-
});
|
|
358
|
-
if (candidates.length === 1) {
|
|
359
|
-
meta.input = candidates[0].schema;
|
|
360
|
-
} else if (
|
|
361
|
-
candidates.length > 1
|
|
362
|
-
&& declared[method.name]?.input === undefined
|
|
363
|
-
&& !explicitInputs.has(method.name)
|
|
364
|
-
) {
|
|
365
|
-
const subject = `${handlerName}.${method.name}`;
|
|
366
|
-
record({
|
|
367
|
-
severity: 'blocking',
|
|
368
|
-
code: 'input-contract-ambiguous',
|
|
369
|
-
filePath,
|
|
370
|
-
subject,
|
|
371
|
-
message: `Cannot infer the input contract for ${subject}: ${candidates.length} entity `
|
|
372
|
-
+ `candidates — ${candidates.map(({ param }) => `${param.name}: ${param.type.raw}`).join('; ')}. `
|
|
373
|
-
+ `Declare operations.${method.name}.input in frond.config.ts.`,
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (method.returnType) {
|
|
378
|
-
meta.output = resolveSchema(method.returnType, moduleExports);
|
|
379
|
-
// `output` is the shape of one row; this says how many rows come back.
|
|
380
|
-
meta.cardinality = cardinalityOf(method.returnType);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
map.set(method.name, meta);
|
|
441
|
+
map.set(method.name, contractOf(method, {
|
|
442
|
+
handlerName, filePath, moduleExports, collectorTypeNames,
|
|
443
|
+
refused: refusals.get(`${handlerName}.${method.name}`),
|
|
444
|
+
stated: declared[method.name]?.input !== undefined || explicitInputs.has(method.name),
|
|
445
|
+
}));
|
|
384
446
|
}
|
|
385
447
|
|
|
386
448
|
return map;
|
|
@@ -460,7 +522,7 @@ async function toPresenterEntry(filePath: string): Promise<PresenterEntry | null
|
|
|
460
522
|
const ctor = await loadClass(filePath);
|
|
461
523
|
const target = targetOf(ctor);
|
|
462
524
|
if (!target) return null;
|
|
463
|
-
const entityName = lowerFirst((target as
|
|
525
|
+
const entityName = lowerFirst((target as { name: string }).name);
|
|
464
526
|
const fields = getPresenterFields(ctor);
|
|
465
527
|
const presenterParams = await ctorParamsOf(filePath);
|
|
466
528
|
const deps = presenterParams.map((p) => depKeyOf(p.type));
|
|
@@ -517,7 +579,7 @@ async function toCollectorEntry(filePath: string): Promise<CollectorEntry | null
|
|
|
517
579
|
if (!target) return null;
|
|
518
580
|
// The target's NAME and nothing else — a collector reads no fields, so the class it
|
|
519
581
|
// was built on needs no schema.
|
|
520
|
-
const typeName = lowerFirst((target as
|
|
582
|
+
const typeName = lowerFirst((target as { name: string }).name);
|
|
521
583
|
const collectorParams = await ctorParamsOf(filePath);
|
|
522
584
|
const deps = collectorParams.map((p) => depKeyOf(p.type));
|
|
523
585
|
return { typeName, ctor, deps, filePath };
|
|
@@ -527,10 +589,7 @@ async function toCollectorEntry(filePath: string): Promise<CollectorEntry | null
|
|
|
527
589
|
* Recognized by its FORM: a class in `middlewares/` that declares `around`. The scope is
|
|
528
590
|
* the frond's own unless `frond.config.ts` widened it, which is why the config comes first.
|
|
529
591
|
*/
|
|
530
|
-
async function toMiddlewareEntry(
|
|
531
|
-
filePath: string,
|
|
532
|
-
scopes: FrondConfig['middlewares'],
|
|
533
|
-
): Promise<MiddlewareEntry | null> {
|
|
592
|
+
async function toMiddlewareEntry(filePath: string): Promise<MiddlewareEntry | null> {
|
|
534
593
|
const ctor = await loadClass(filePath);
|
|
535
594
|
const prototype = (ctor as { prototype?: { around?: unknown } }).prototype;
|
|
536
595
|
if (typeof prototype?.around !== 'function') return null;
|
|
@@ -539,12 +598,48 @@ async function toMiddlewareEntry(
|
|
|
539
598
|
return {
|
|
540
599
|
name: ctor.name,
|
|
541
600
|
ctor,
|
|
542
|
-
scope: scopes?.[ctor.name] ?? 'frond',
|
|
543
601
|
deps: params.map((p) => depKeyOf(p.type)),
|
|
544
602
|
filePath,
|
|
545
603
|
};
|
|
546
604
|
}
|
|
547
605
|
|
|
606
|
+
/** `frond.config.ts` takes precedence; failing that, `@expose`, which defaults to exposed. */
|
|
607
|
+
function markExposed(
|
|
608
|
+
entities: EntityEntry[],
|
|
609
|
+
handlers: HandlerEntry[],
|
|
610
|
+
exposed: readonly string[] | undefined,
|
|
611
|
+
): void {
|
|
612
|
+
if (exposed) {
|
|
613
|
+
const stated = new Set(exposed);
|
|
614
|
+
for (const entity of entities) entity.exposed = stated.has((entity.entityClass as { name: string }).name);
|
|
615
|
+
for (const handler of handlers) handler.exposed = stated.has(handler.ctor.name);
|
|
616
|
+
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
for (const entity of entities) entity.exposed = (entity.entityClass as { __exposed?: boolean }).__exposed !== false;
|
|
621
|
+
for (const handler of handlers) handler.exposed = (handler.ctor as { __exposed?: boolean }).__exposed !== false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* The only thing that needs flattening is the handler CLASS, which becomes its name — that is the
|
|
626
|
+
* DI key. Everything else travels verbatim, so a slot added to `OperationOverride` reaches its
|
|
627
|
+
* reader without a stop here; enumerating keys by hand is what used to drop whatever was added
|
|
628
|
+
* last, the same invariant `cloneField` holds one layer down.
|
|
629
|
+
*/
|
|
630
|
+
function overridesOf(
|
|
631
|
+
operations: FrondConfig['operations'],
|
|
632
|
+
): FrondDescriptor['operationsOverrides'] {
|
|
633
|
+
if (!operations) return undefined;
|
|
634
|
+
|
|
635
|
+
return Object.fromEntries(
|
|
636
|
+
Object.entries(operations).map(([opName, { handler, ...rest }]) => [
|
|
637
|
+
opName,
|
|
638
|
+
{ ...rest, handlerName: handler?.name },
|
|
639
|
+
]),
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
548
643
|
async function scanFrond(frondPath: string, name: string, source: FrondDescriptor['source'], conventions: Conventions, projectRoot?: string): Promise<FrondDescriptor> {
|
|
549
644
|
const {
|
|
550
645
|
entities: entitiesDir, handlers: handlersDir,
|
|
@@ -594,41 +689,10 @@ async function scanFrond(frondPath: string, name: string, source: FrondDescripto
|
|
|
594
689
|
|
|
595
690
|
const presenters = await collect(presentersDir, toPresenterEntry);
|
|
596
691
|
const seeds = await collect(seedsDir, toSeedEntry);
|
|
597
|
-
const middlewares = await collect(middlewaresDir, (f) => toMiddlewareEntry(f
|
|
692
|
+
const middlewares = await collect(middlewaresDir, (f) => toMiddlewareEntry(f));
|
|
598
693
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const exposeSet = new Set(frondConfig.expose);
|
|
602
|
-
for (const e of entities) {
|
|
603
|
-
e.exposed = exposeSet.has((e.entityClass as any).name);
|
|
604
|
-
}
|
|
605
|
-
for (const h of handlers) {
|
|
606
|
-
h.exposed = exposeSet.has(h.ctor.name);
|
|
607
|
-
}
|
|
608
|
-
} else {
|
|
609
|
-
// Fallback: check @expose decorator, default to true (expose everything unless explicitly hidden)
|
|
610
|
-
for (const e of entities) {
|
|
611
|
-
e.exposed = (e.entityClass as any).__exposed !== false;
|
|
612
|
-
}
|
|
613
|
-
for (const h of handlers) {
|
|
614
|
-
h.exposed = (h.ctor as any).__exposed !== false;
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Flatten per-op config overrides: the only thing that needs flattening is the handler
|
|
619
|
-
// CLASS, which becomes its name (that is the DI key). Everything else — the surface keys
|
|
620
|
-
// AND the contract keys (`input`, `binding`) — travels verbatim, so a slot added to
|
|
621
|
-
// OperationOverride reaches its reader without a stop here. Enumerating keys by hand is
|
|
622
|
-
// what used to silently drop whatever was added last (the same invariant `cloneField`
|
|
623
|
-
// holds one layer down).
|
|
624
|
-
const operationsOverrides = frondConfig?.operations
|
|
625
|
-
? Object.fromEntries(
|
|
626
|
-
Object.entries(frondConfig.operations).map(([opName, { handler, ...rest }]) => [
|
|
627
|
-
opName,
|
|
628
|
-
{ ...rest, handlerName: handler?.name },
|
|
629
|
-
]),
|
|
630
|
-
)
|
|
631
|
-
: undefined;
|
|
694
|
+
markExposed(entities, handlers, frondConfig?.expose);
|
|
695
|
+
const operationsOverrides = overridesOf(frondConfig?.operations);
|
|
632
696
|
|
|
633
697
|
return {
|
|
634
698
|
name,
|