@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.
@@ -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 any);
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
- if (type.name === 'Partial' && 'partial' in resolved && typeof (resolved as any).partial === 'function') {
268
- return (resolved as any).partial() as SchemaView;
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
- // The contract is what carries the description; `signature` is the raw material it
338
- // was read from. Leaving it only on the signature meant every consumer had to know
339
- // to look one level down, and only the façade did.
340
- const refused = refusals.get(`${handlerName}.${method.name}`);
341
- const meta: OperationContract = {
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 any).name);
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 any).name);
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, frondConfig?.middlewares));
692
+ const middlewares = await collect(middlewaresDir, (f) => toMiddlewareEntry(f));
598
693
 
599
- // Mark exposed entries: frond.config.ts takes precedence, then @expose decorator
600
- if (frondConfig?.expose) {
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,