@fougere/core 0.3.0-alpha.0 → 0.5.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.
Files changed (243) hide show
  1. package/README.md +1 -1
  2. package/dist/boot/Emissions.js +1 -1
  3. package/dist/boot/Emissions.js.map +1 -1
  4. package/dist/boot/HandlerFacade.js +2 -2
  5. package/dist/boot/HandlerFacade.js.map +1 -1
  6. package/dist/boot/apply.d.ts.map +1 -1
  7. package/dist/boot/apply.js +2 -12
  8. package/dist/boot/apply.js.map +1 -1
  9. package/dist/boot/binding.d.ts +1 -1
  10. package/dist/boot/binding.d.ts.map +1 -1
  11. package/dist/boot/binding.js +4 -4
  12. package/dist/boot/binding.js.map +1 -1
  13. package/dist/boot/boot.d.ts +1 -1
  14. package/dist/boot/boot.d.ts.map +1 -1
  15. package/dist/boot/bootstrap.d.ts.map +1 -1
  16. package/dist/boot/bootstrap.js +26 -17
  17. package/dist/boot/bootstrap.js.map +1 -1
  18. package/dist/boot/frame.d.ts +1 -1
  19. package/dist/boot/frame.d.ts.map +1 -1
  20. package/dist/boot/frame.js +5 -14
  21. package/dist/boot/frame.js.map +1 -1
  22. package/dist/boot/hosted.d.ts +29 -0
  23. package/dist/boot/hosted.d.ts.map +1 -0
  24. package/dist/boot/hosted.js +38 -0
  25. package/dist/boot/hosted.js.map +1 -0
  26. package/dist/boot/ownership.d.ts +0 -3
  27. package/dist/boot/ownership.d.ts.map +1 -1
  28. package/dist/boot/ownership.js +5 -5
  29. package/dist/boot/ownership.js.map +1 -1
  30. package/dist/boot/remote.d.ts +1 -1
  31. package/dist/boot/remote.d.ts.map +1 -1
  32. package/dist/boot/remote.js +3 -3
  33. package/dist/boot/remote.js.map +1 -1
  34. package/dist/boot/statement-drift.d.ts +24 -0
  35. package/dist/boot/statement-drift.d.ts.map +1 -0
  36. package/dist/boot/statement-drift.js +38 -0
  37. package/dist/boot/statement-drift.js.map +1 -0
  38. package/dist/boot/together.d.ts.map +1 -1
  39. package/dist/boot/together.js +6 -6
  40. package/dist/boot/together.js.map +1 -1
  41. package/dist/boot/types.d.ts +17 -4
  42. package/dist/boot/types.d.ts.map +1 -1
  43. package/dist/contract/CallLog.d.ts +57 -0
  44. package/dist/contract/CallLog.d.ts.map +1 -0
  45. package/dist/contract/CallLog.js +2 -0
  46. package/dist/contract/CallLog.js.map +1 -0
  47. package/dist/contract.d.ts +9 -4
  48. package/dist/contract.d.ts.map +1 -1
  49. package/dist/contract.js +12 -5
  50. package/dist/contract.js.map +1 -1
  51. package/dist/dispatch/DispatchLifecycle.d.ts +9 -1
  52. package/dist/dispatch/DispatchLifecycle.d.ts.map +1 -1
  53. package/dist/dispatch/DispatchLifecycle.js +17 -2
  54. package/dist/dispatch/DispatchLifecycle.js.map +1 -1
  55. package/dist/dispatch/InFlight.d.ts +0 -1
  56. package/dist/dispatch/InFlight.d.ts.map +1 -1
  57. package/dist/dispatch/InFlight.js +0 -3
  58. package/dist/dispatch/InFlight.js.map +1 -1
  59. package/dist/dispatch/InputValidator.d.ts.map +1 -1
  60. package/dist/dispatch/InputValidator.js +3 -2
  61. package/dist/dispatch/InputValidator.js.map +1 -1
  62. package/dist/dispatch/OutputProjector.js +2 -2
  63. package/dist/dispatch/OutputProjector.js.map +1 -1
  64. package/dist/dispatch/StorageGuard.d.ts.map +1 -1
  65. package/dist/dispatch/StorageGuard.js +2 -2
  66. package/dist/dispatch/StorageGuard.js.map +1 -1
  67. package/dist/effective-operation.d.ts.map +1 -1
  68. package/dist/effective-operation.js +8 -5
  69. package/dist/effective-operation.js.map +1 -1
  70. package/dist/emit.d.ts +2 -2
  71. package/dist/emit.d.ts.map +1 -1
  72. package/dist/emit.js +2 -2
  73. package/dist/emit.js.map +1 -1
  74. package/dist/identity.js +1 -1
  75. package/dist/identity.js.map +1 -1
  76. package/dist/imports.d.ts +1 -1
  77. package/dist/imports.d.ts.map +1 -1
  78. package/dist/imports.js.map +1 -1
  79. package/dist/index.d.ts +8 -2
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +12 -1
  82. package/dist/index.js.map +1 -1
  83. package/dist/node.d.ts +2 -0
  84. package/dist/node.d.ts.map +1 -1
  85. package/dist/node.js +2 -0
  86. package/dist/node.js.map +1 -1
  87. package/dist/orm.d.ts.map +1 -1
  88. package/dist/orm.js +4 -4
  89. package/dist/orm.js.map +1 -1
  90. package/dist/prefab/collector.js +2 -2
  91. package/dist/prefab/collector.js.map +1 -1
  92. package/dist/prefab/mirror.d.ts.map +1 -1
  93. package/dist/prefab/mirror.js +2 -2
  94. package/dist/prefab/mirror.js.map +1 -1
  95. package/dist/prefab/presenter.d.ts.map +1 -1
  96. package/dist/prefab/presenter.js +2 -2
  97. package/dist/prefab/presenter.js.map +1 -1
  98. package/dist/prefab/repository.d.ts.map +1 -1
  99. package/dist/prefab/repository.js +2 -2
  100. package/dist/prefab/repository.js.map +1 -1
  101. package/dist/scan/bundling.d.ts +14 -0
  102. package/dist/scan/bundling.d.ts.map +1 -0
  103. package/dist/scan/bundling.js +26 -0
  104. package/dist/scan/bundling.js.map +1 -0
  105. package/dist/scan/declare.d.ts +71 -0
  106. package/dist/scan/declare.d.ts.map +1 -0
  107. package/dist/scan/declare.js +125 -0
  108. package/dist/scan/declare.js.map +1 -0
  109. package/dist/scan/emit.d.ts.map +1 -1
  110. package/dist/scan/emit.js +3 -2
  111. package/dist/scan/emit.js.map +1 -1
  112. package/dist/scan/handler-parser.d.ts +2 -0
  113. package/dist/scan/handler-parser.d.ts.map +1 -1
  114. package/dist/scan/handler-parser.js +52 -13
  115. package/dist/scan/handler-parser.js.map +1 -1
  116. package/dist/scan/scanner.d.ts.map +1 -1
  117. package/dist/scan/scanner.js +37 -11
  118. package/dist/scan/scanner.js.map +1 -1
  119. package/dist/scan/statement.d.ts +4 -0
  120. package/dist/scan/statement.d.ts.map +1 -0
  121. package/dist/scan/statement.js +112 -0
  122. package/dist/scan/statement.js.map +1 -0
  123. package/dist/verify.js +2 -2
  124. package/dist/verify.js.map +1 -1
  125. package/dist/wire/call.d.ts +19 -8
  126. package/dist/wire/call.d.ts.map +1 -1
  127. package/dist/wire/call.js +17 -6
  128. package/dist/wire/call.js.map +1 -1
  129. package/dist/wire/drift.d.ts +55 -0
  130. package/dist/wire/drift.d.ts.map +1 -0
  131. package/dist/wire/drift.js +121 -0
  132. package/dist/wire/drift.js.map +1 -0
  133. package/dist/wire/middleware.d.ts +1 -1
  134. package/dist/wire/middleware.d.ts.map +1 -1
  135. package/package.json +6 -4
  136. package/src/boot/AppLifecycle.ts +50 -0
  137. package/src/boot/Emissions.ts +263 -0
  138. package/src/boot/HandlerFacade.ts +242 -0
  139. package/src/boot/ambient-port.ts +41 -0
  140. package/src/boot/ambient.als.ts +43 -0
  141. package/src/boot/ambient.queue.ts +75 -0
  142. package/src/boot/apply.ts +47 -0
  143. package/src/boot/auth.ts +55 -0
  144. package/src/boot/binding.ts +182 -0
  145. package/src/boot/boot.ts +125 -0
  146. package/src/boot/bootstrap.ts +847 -0
  147. package/src/boot/frame.ts +210 -0
  148. package/src/boot/hosted.ts +77 -0
  149. package/src/boot/ownership.ts +137 -0
  150. package/src/boot/ports.ts +66 -0
  151. package/src/boot/remote.ts +179 -0
  152. package/src/boot/seed.ts +158 -0
  153. package/src/boot/statement-drift.ts +60 -0
  154. package/src/boot/together.ts +215 -0
  155. package/src/boot/types.ts +274 -0
  156. package/src/builtins/config.ts +19 -0
  157. package/src/builtins/logger.ts +175 -0
  158. package/src/config-loader.ts +146 -0
  159. package/src/contract/Call.ts +18 -0
  160. package/src/contract/CallLog.ts +55 -0
  161. package/src/contract/Invocation.ts +72 -0
  162. package/src/contract/RouteAddress.ts +49 -0
  163. package/src/contract.ts +83 -0
  164. package/src/crypto/encoding.ts +59 -0
  165. package/src/crypto/node.ts +30 -0
  166. package/src/crypto/port.ts +45 -0
  167. package/src/crypto/webcrypto.ts +55 -0
  168. package/src/define.ts +10 -0
  169. package/src/dispatch/ArgumentResolver.ts +17 -0
  170. package/src/dispatch/ArrayResult.ts +10 -0
  171. package/src/dispatch/DispatchEvent.ts +38 -0
  172. package/src/dispatch/DispatchLifecycle.ts +43 -0
  173. package/src/dispatch/DispatchPort.ts +6 -0
  174. package/src/dispatch/Dispatcher.ts +48 -0
  175. package/src/dispatch/InFlight.ts +45 -0
  176. package/src/dispatch/InputValidator.ts +28 -0
  177. package/src/dispatch/LocalRoutePolicy.ts +34 -0
  178. package/src/dispatch/OperationExecutor.ts +60 -0
  179. package/src/dispatch/OperationRoute.ts +18 -0
  180. package/src/dispatch/OutputProjector.ts +32 -0
  181. package/src/dispatch/OutputView.ts +9 -0
  182. package/src/dispatch/PresenterArgumentResolver.ts +25 -0
  183. package/src/dispatch/PresenterExecutor.ts +55 -0
  184. package/src/dispatch/RemoteRouteResolver.ts +21 -0
  185. package/src/dispatch/Route.ts +11 -0
  186. package/src/dispatch/RouteNotFoundError.ts +29 -0
  187. package/src/dispatch/RoutePolicy.ts +8 -0
  188. package/src/dispatch/RouteRegistry.ts +73 -0
  189. package/src/dispatch/RouteResolver.ts +7 -0
  190. package/src/dispatch/StorageGuard.ts +71 -0
  191. package/src/effective-operation.ts +601 -0
  192. package/src/emit.ts +90 -0
  193. package/src/entry/DynamicFacade.ts +28 -0
  194. package/src/entry/FacadeEntry.ts +32 -0
  195. package/src/entry/TransportEntry.ts +30 -0
  196. package/src/frond-config.ts +138 -0
  197. package/src/graph.ts +180 -0
  198. package/src/identity-keys.ts +43 -0
  199. package/src/identity.ts +235 -0
  200. package/src/imports.ts +123 -0
  201. package/src/index.ts +120 -0
  202. package/src/loader.ts +41 -0
  203. package/src/node.ts +30 -0
  204. package/src/orm.ts +252 -0
  205. package/src/prefab/collector.ts +36 -0
  206. package/src/prefab/crud.ts +188 -0
  207. package/src/prefab/mirror.ts +122 -0
  208. package/src/prefab/prefab.ts +46 -0
  209. package/src/prefab/presenter.ts +63 -0
  210. package/src/prefab/repository.ts +170 -0
  211. package/src/scan/Fronds.ts +73 -0
  212. package/src/scan/bundling.ts +25 -0
  213. package/src/scan/conventions.ts +122 -0
  214. package/src/scan/declare.ts +188 -0
  215. package/src/scan/emit.ts +231 -0
  216. package/src/scan/frond.ts +235 -0
  217. package/src/scan/handler-parser.ts +817 -0
  218. package/src/scan/scanner.ts +757 -0
  219. package/src/scan/statement.ts +126 -0
  220. package/src/verify.ts +193 -0
  221. package/src/wire/call.ts +381 -0
  222. package/src/wire/drift.ts +131 -0
  223. package/src/wire/errors.ts +124 -0
  224. package/src/wire/http-error.ts +77 -0
  225. package/src/wire/loggerMiddleware.ts +29 -0
  226. package/src/wire/middleware.ts +47 -0
  227. package/src/wire/operation.ts +258 -0
  228. package/dist/boot/Lifecycle.d.ts +0 -4
  229. package/dist/boot/Lifecycle.d.ts.map +0 -1
  230. package/dist/boot/Lifecycle.js +0 -3
  231. package/dist/boot/Lifecycle.js.map +0 -1
  232. package/dist/boot/egress.d.ts +0 -8
  233. package/dist/boot/egress.d.ts.map +0 -1
  234. package/dist/boot/egress.js +0 -11
  235. package/dist/boot/egress.js.map +0 -1
  236. package/dist/scan/scan-cache.d.ts +0 -16
  237. package/dist/scan/scan-cache.d.ts.map +0 -1
  238. package/dist/scan/scan-cache.js +0 -99
  239. package/dist/scan/scan-cache.js.map +0 -1
  240. package/dist/wire/invocation.d.ts +0 -4
  241. package/dist/wire/invocation.d.ts.map +0 -1
  242. package/dist/wire/invocation.js +0 -3
  243. package/dist/wire/invocation.js.map +0 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The scan, written as the STATEMENT an author would have written.
3
+ *
4
+ * A scan runs at build and the app runs elsewhere — two runtimes, no shared memory — so
5
+ * something has to cross. What used to cross was the scan RESULT: every operation's
6
+ * TypeScript signature re-serialized as JSON, beside the five CRUD ops `Crud.__ops`
7
+ * declares at runtime anyway. A second writing of what the classes carry, and one that
8
+ * drifts, since nothing compares the two.
9
+ *
10
+ * What crosses here is what `fronds.ts` holds: imports that bring the ORIGINAL classes
11
+ * back, and `frond()` calls. Everything else `frond()` derives — `Post` is stored as
12
+ * `post`, `PostHandler` answers at `post`, a computed field IS a method, and
13
+ * `Presenter(Post)` keeps its subject. Only what TypeScript erases is named: a
14
+ * constructor's parameter types (`deps`), and the surface a handler answers on, which the
15
+ * scan read from its directory.
16
+ *
17
+ * Measured on demos/nuxt-blog: 102 lines of scan against 31 of statement, and the same app
18
+ * — same rows, same computed fields, and no `typescript` loaded at boot.
19
+ */
20
+ import { relative } from 'node:path';
21
+ import type { FrondDescriptor, ScanResult } from './frond.js';
22
+
23
+ type Live = { name?: string };
24
+
25
+ /**
26
+ * A file becomes the specifier the PROJECT already uses for it: `@fronds/blog/…`, the
27
+ * import scope a handler names its neighbour by, and which the Nuxt module registers as an
28
+ * alias for every frond it found.
29
+ *
30
+ * A relative path would name the same file by a second route, and two routes to one module
31
+ * are two modules — so `Post` would not equal `Post` and every identity check would fail
32
+ * quietly. It also puts the file outside what the alias resolves, which is what made Node,
33
+ * not the bundler, read the frond's `.ts` and answer 500 on `../entities/Post.js`.
34
+ *
35
+ * `.js`, because the source is `.ts` and the project spells a TypeScript source that way
36
+ * everywhere else — the alias resolves through the same rule as a hand-written import.
37
+ */
38
+ function specifierOf(filePath: string, frond: FrondDescriptor): string {
39
+ const inside = relative(frond.source.path, filePath).replace(/\.tsx?$/, '.js');
40
+
41
+ return `${frond.source.package}/${inside}`;
42
+ }
43
+
44
+ /** One alias per file, so a class imported twice is one binding and one identity. */
45
+ class Imports {
46
+ private readonly byPath = new Map<string, string>();
47
+ private readonly lines: string[] = [];
48
+
49
+ default(filePath: string, frond: FrondDescriptor): string {
50
+ const known = this.byPath.get(filePath);
51
+ if (known) return known;
52
+ const alias = `_${this.byPath.size}`;
53
+ this.lines.push(`import ${alias} from '${specifierOf(filePath, frond)}';`);
54
+ this.byPath.set(filePath, alias);
55
+
56
+ return alias;
57
+ }
58
+
59
+ render(): string {
60
+ return this.lines.join('\n');
61
+ }
62
+ }
63
+
64
+ /** `{ ctor: X, deps: [...] }` when there is something to say, the bare class otherwise. */
65
+ function subject(alias: string, deps: string[], extra = ''): string {
66
+ const parts = [
67
+ ...(deps.length ? [`deps: ${JSON.stringify(deps)}`] : []),
68
+ ...(extra ? [extra] : []),
69
+ ];
70
+
71
+ return parts.length ? `{ ctor: ${alias}, ${parts.join(', ')} }` : alias;
72
+ }
73
+
74
+ function frondOf(frond: FrondDescriptor, imports: Imports): string {
75
+ const list = (items: string[]): string => `[${items.join(', ')}]`;
76
+ const members: string[] = [];
77
+
78
+ if (frond.entities.length) {
79
+ members.push(`entities: ${list(frond.entities.map((e) => imports.default(e.filePath, frond)))}`);
80
+ }
81
+ if (frond.handlers.length) {
82
+ members.push(`handlers: ${list(frond.handlers.map((h) =>
83
+ subject(imports.default(h.filePath, frond), h.deps, h.surface ? `surface: ${JSON.stringify(h.surface)}` : '')))}`);
84
+ }
85
+ if (frond.presenters.length) {
86
+ members.push(`presenters: ${list(frond.presenters.map((p) => subject(imports.default(p.filePath, frond), p.deps)))}`);
87
+ }
88
+ if (frond.collectors.length) {
89
+ members.push(`collectors: ${list(frond.collectors.map((c) => subject(imports.default(c.filePath, frond), c.deps)))}`);
90
+ }
91
+ if (frond.providers.length) {
92
+ members.push(`providers: ${list(frond.providers.map((p) => subject(imports.default(p.filePath, frond), p.deps)))}`);
93
+ }
94
+ // A seed is DATA, not a class — the one member a statement cannot derive from an import.
95
+ if (frond.seeds.length) {
96
+ members.push(`seeds: ${JSON.stringify(frond.seeds.map((s) => ({ entityName: s.entityName, data: s.data })))}`);
97
+ }
98
+ if (frond.surfaces) members.push(`surfaces: ${JSON.stringify(frond.surfaces)}`);
99
+
100
+ const scope = (frond.source.package as string | undefined)?.split('/')[0];
101
+ if (scope) members.push(`scope: ${JSON.stringify(scope)}`);
102
+
103
+ return ` frond(${JSON.stringify(frond.name)}, {\n ${members.join(',\n ')},\n })`;
104
+ }
105
+
106
+ /** The scan as a list of `frond()` calls — the file an author writes, written for them. */
107
+ // Nothing is written relative to the destination, so where the file SITS is not a
108
+ // parameter — the alias resolves the same from anywhere.
109
+ export function emitStatement(scan: ScanResult): string {
110
+ const imports = new Imports();
111
+ const fronds = scan.fronds.map((f) => frondOf(f as FrondDescriptor & { source: Live }, imports));
112
+
113
+ return [
114
+ '// Generated by @fougere/nuxt — what the scan found, as the statement you would write.',
115
+ '//',
116
+ '// Classes are IMPORTED, never restated: `frond()` derives every name from them. Write',
117
+ '// your own `fronds.ts` beside `fougere.config.ts` to state this by hand instead.',
118
+ "import { frond } from '@fougere/core';",
119
+ imports.render(),
120
+ '',
121
+ 'export default [',
122
+ `${fronds.join(',\n')},`,
123
+ '];',
124
+ '',
125
+ ].join('\n');
126
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,193 @@
1
+ import type { FrondDescriptor } from './scan/frond.js';
2
+ import { lowerFirst } from '@fougere/schema';
3
+ import { repositoryKeyOf } from './prefab/repository.js';
4
+ import { ormKeyOf } from './orm.js';
5
+ import { presenterKeyOf } from './prefab/presenter.js';
6
+ import { collectorKeyOf } from './prefab/collector.js';
7
+
8
+ /**
9
+ * What a rule found in an app.
10
+ *
11
+ * `message` is the sentence the rule replaces — the prose a human used to have
12
+ * to remember. `Known issues` says "Keep collectors in the consuming frond";
13
+ * this is that sentence, told about a specific handler, by something that runs.
14
+ */
15
+ export interface Violation {
16
+ /** Rule name, stable — 'cross-frond-dependency'. */
17
+ rule: string;
18
+ /**
19
+ * What it costs, decided by the rule that raises it — never by whoever renders it.
20
+ *
21
+ * The two rules here are not the same animal: a cross-frond dependency resolves
22
+ * today and stops resolving the day the other frond answers over the wire, while a
23
+ * collector declared elsewhere is already wrong in one process. A reader holding a
24
+ * table of rule names would be a second opinion on a fact the rule already has.
25
+ */
26
+ severity: 'blocking' | 'warning';
27
+ /** The frond the subject lives in. */
28
+ frond: string;
29
+ /** What violates it — 'PostHandler'. */
30
+ subject: string;
31
+ /** Where to go and look. */
32
+ filePath: string;
33
+ /**
34
+ * What the subject reaches for, and where it actually lives.
35
+ *
36
+ * `frond` above is the CONSUMER; the target used to exist only inside the
37
+ * sentence. A reader that has to parse prose to learn which frond a violation
38
+ * points at is a second opinion on a fact the checker already held — so the
39
+ * boot can ask "does this violation target a frond named in `remotes:`?" by
40
+ * reading a field.
41
+ */
42
+ dependsOn: { key: string; frond: string; kind: string };
43
+ /** What breaks, and what the caller gets instead. */
44
+ message: string;
45
+ }
46
+
47
+ /** A dependency declared in a frond's scope, and what kind of thing it is. */
48
+ type Registration = { frond: string; kind: string };
49
+
50
+ /**
51
+ * The container keys a frond registers in its own scope, keyed as a handler's
52
+ * `deps` spell them — DI resolves by type name, so both sides are PascalCase.
53
+ *
54
+ * Every key comes from the function that states it, never from a derivation
55
+ * respelled here: a second reader that spells one differently finds nothing and
56
+ * reports nothing wrong, which is the failure mode a checker must not have.
57
+ */
58
+ function registrationsOf(frond: FrondDescriptor): Map<string, Registration> {
59
+ const out = new Map<string, Registration>();
60
+ const put = (key: string, kind: string) => out.set(key, { frond: frond.name, kind });
61
+
62
+ for (const p of frond.providers) put(p.ctor.name, 'provider');
63
+ for (const p of frond.presenters) put(presenterKeyOf(p.entityName), 'presenter');
64
+ for (const c of frond.collectors) put(collectorKeyOf(c.typeName), 'collector');
65
+ for (const e of frond.entities) {
66
+ put(ormKeyOf(e.name), 'ORM');
67
+ put(repositoryKeyOf(e.name), 'repository');
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * Everything in a frond that is constructed by DI, so carries `deps`.
74
+ * Handlers, presenters and collectors are all built from a constructor whose
75
+ * argument types the scan read; each one can therefore reach across.
76
+ */
77
+ function injectablesOf(frond: FrondDescriptor) {
78
+ return [
79
+ ...frond.handlers.map((h) => ({ name: h.ctor.name, deps: h.deps, filePath: h.filePath })),
80
+ ...frond.presenters.map((p) => ({ name: p.ctor.name, deps: p.deps, filePath: p.filePath })),
81
+ ...frond.collectors.map((c) => ({ name: c.ctor.name, deps: c.deps, filePath: c.filePath })),
82
+ ];
83
+ }
84
+
85
+ /**
86
+ * Does this app survive being split?
87
+ *
88
+ * A frond runs in-process or in its own process behind JSON-RPC with identical
89
+ * user code — that is the whole claim. What the claim does not say is that
90
+ * *every* app survives the move: a dependency that crosses a frond boundary
91
+ * resolves today because both scopes live in one container, and resolves to
92
+ * nothing the day the other frond answers over the wire.
93
+ *
94
+ * The question only exists where a module boundary is meant to become a process
95
+ * boundary later. Where every inter-module call is already remote there is
96
+ * nothing to check, and where no exit is planned there is nothing to check
97
+ * either. Here it means something, and until now it had no answer.
98
+ *
99
+ * Pure over `app.fronds`: no mount, no process, no file. Call it from a test.
100
+ */
101
+ export function verify(app: { fronds: readonly FrondDescriptor[] }): Violation[] {
102
+ const index = new Map<string, Registration>();
103
+ for (const frond of app.fronds) {
104
+ for (const [key, reg] of registrationsOf(frond)) index.set(key, reg);
105
+ }
106
+
107
+ // Which frond declares a collector for which entity. Collectors are the one
108
+ // thing a handler asks for by OPERATION PARAMETER, never by constructor, so
109
+ // they need their own index and their own rule.
110
+ const collectorFronds = new Map<string, string>();
111
+ for (const frond of app.fronds) {
112
+ for (const c of frond.collectors) collectorFronds.set(c.typeName, frond.name);
113
+ }
114
+
115
+ const violations: Violation[] = [];
116
+ for (const frond of app.fronds) {
117
+ // Rule 1 — a constructor dependency declared in another frond's scope.
118
+ // Façade keys are exempt by construction, not by omission: a `Facade<X>`
119
+ // dependency is spelled camelCase (`articleHandler`) while every type key
120
+ // here is PascalCase, so the two namespaces cannot collide. The façade IS
121
+ // the sanctioned crossing — see tests/cross-frond.test.ts.
122
+ for (const subject of injectablesOf(frond)) {
123
+ for (const dep of subject.deps) {
124
+ const declared = index.get(dep);
125
+ // Not in the index at all = a builtin (Logger, Config), a
126
+ // façade key, or an unresolved name. None is a boundary crossing, and
127
+ // an unresolved dependency is the container's complaint, not this rule's.
128
+ if (!declared || declared.frond === frond.name) continue;
129
+ violations.push({
130
+ rule: 'cross-frond-dependency',
131
+ severity: 'warning',
132
+ frond: frond.name,
133
+ subject: subject.name,
134
+ filePath: subject.filePath,
135
+ dependsOn: { key: dep, frond: declared.frond, kind: declared.kind },
136
+ message:
137
+ `${subject.name} depends on ${dep}, the ${declared.kind} of frond '${declared.frond}'. ` +
138
+ `Each frond registers into its own scope, so this does not resolve here. ` +
139
+ `Declare ${dep} in '${frond.name}', or reach '${declared.frond}' through its ` +
140
+ `façade — the one sanctioned crossing.`,
141
+ });
142
+ }
143
+ }
144
+
145
+ // Rule 2 — an operation parameter that wanted a collector this frond has not
146
+ // got. The preliminary binding convention can only see this frond's collector
147
+ // set, so it produces body evidence. EffectiveOperation refuses that evidence
148
+ // before boot: another frond's collector proves the provenance is not a body.
149
+ const own = new Set(frond.collectors.map((c) => c.typeName));
150
+ for (const handler of frond.handlers) {
151
+ for (const [opName, op] of handler.operations) {
152
+ for (const param of op.signature?.params ?? []) {
153
+ // Same key the scan writes and the binding plan looks up — `toLowerCase()`
154
+ // here missed a two-word type in BOTH directions: neither confirmed the
155
+ // collector was local nor found it elsewhere, so the rule reported nothing.
156
+ const wanted = lowerFirst(param.type.name);
157
+ if (own.has(wanted)) continue;
158
+ const elsewhere = collectorFronds.get(wanted);
159
+ // No collector anywhere for that type may be an ordinary body parameter.
160
+ // Only a collector that exists in the wrong place makes that interpretation
161
+ // provably false.
162
+ if (!elsewhere) continue;
163
+ violations.push({
164
+ rule: 'collector-in-another-frond',
165
+ severity: 'blocking',
166
+ frond: frond.name,
167
+ subject: `${handler.ctor.name}.${opName}(${param.name})`,
168
+ filePath: handler.filePath,
169
+ dependsOn: { key: collectorKeyOf(wanted), frond: elsewhere, kind: 'collector' },
170
+ message:
171
+ `'${param.name}' is typed ${param.type.name}, and the collector that produces one ` +
172
+ `is declared in frond '${elsewhere}'. A binding plan only sees its own frond's ` +
173
+ `collectors, so the preliminary body interpretation is invalid. Fougere refuses ` +
174
+ `the operation before it can receive what the caller sent. ` +
175
+ `Move the collector into '${frond.name}'.`,
176
+ });
177
+ }
178
+ }
179
+ }
180
+ }
181
+ return violations;
182
+ }
183
+
184
+ /**
185
+ * The same verdict, as an assertion. Throws naming every rule that failed, so a
186
+ * test is one line and its output is the sentence — not a diff of two objects.
187
+ */
188
+ export function assertSplittable(app: { fronds: readonly FrondDescriptor[] }): void {
189
+ const violations = verify(app);
190
+ if (violations.length === 0) return;
191
+ const lines = violations.map((v) => ` [${v.rule}] ${v.frond}/${v.subject}\n ${v.message}\n ${v.filePath}`);
192
+ throw new Error(`${violations.length} violation(s) — this app does not survive a split:\n${lines.join('\n')}`);
193
+ }