@typed/app 1.0.0-beta.1

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 (116) hide show
  1. package/README.md +166 -0
  2. package/dist/HttpApiVirtualModulePlugin.d.ts +26 -0
  3. package/dist/HttpApiVirtualModulePlugin.d.ts.map +1 -0
  4. package/dist/HttpApiVirtualModulePlugin.js +301 -0
  5. package/dist/RouterVirtualModulePlugin.d.ts +23 -0
  6. package/dist/RouterVirtualModulePlugin.d.ts.map +1 -0
  7. package/dist/RouterVirtualModulePlugin.js +176 -0
  8. package/dist/createTypeInfoApiSessionForApp.d.ts +29 -0
  9. package/dist/createTypeInfoApiSessionForApp.d.ts.map +1 -0
  10. package/dist/createTypeInfoApiSessionForApp.js +46 -0
  11. package/dist/httpapi/defineApiHandler.d.ts +70 -0
  12. package/dist/httpapi/defineApiHandler.d.ts.map +1 -0
  13. package/dist/httpapi/defineApiHandler.js +23 -0
  14. package/dist/index.d.ts +9 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/internal/appConfigTypes.d.ts +11 -0
  18. package/dist/internal/appConfigTypes.d.ts.map +1 -0
  19. package/dist/internal/appConfigTypes.js +1 -0
  20. package/dist/internal/appLayerTypes.d.ts +24 -0
  21. package/dist/internal/appLayerTypes.d.ts.map +1 -0
  22. package/dist/internal/appLayerTypes.js +28 -0
  23. package/dist/internal/buildRouteDescriptors.d.ts +48 -0
  24. package/dist/internal/buildRouteDescriptors.d.ts.map +1 -0
  25. package/dist/internal/buildRouteDescriptors.js +371 -0
  26. package/dist/internal/emitHttpApiSource.d.ts +18 -0
  27. package/dist/internal/emitHttpApiSource.d.ts.map +1 -0
  28. package/dist/internal/emitHttpApiSource.js +404 -0
  29. package/dist/internal/emitRouterHelpers.d.ts +17 -0
  30. package/dist/internal/emitRouterHelpers.d.ts.map +1 -0
  31. package/dist/internal/emitRouterHelpers.js +74 -0
  32. package/dist/internal/emitRouterSource.d.ts +8 -0
  33. package/dist/internal/emitRouterSource.d.ts.map +1 -0
  34. package/dist/internal/emitRouterSource.js +139 -0
  35. package/dist/internal/extractHttpApiLiterals.d.ts +17 -0
  36. package/dist/internal/extractHttpApiLiterals.d.ts.map +1 -0
  37. package/dist/internal/extractHttpApiLiterals.js +45 -0
  38. package/dist/internal/httpapiDescriptorTree.d.ts +75 -0
  39. package/dist/internal/httpapiDescriptorTree.d.ts.map +1 -0
  40. package/dist/internal/httpapiDescriptorTree.js +182 -0
  41. package/dist/internal/httpapiEndpointContract.d.ts +32 -0
  42. package/dist/internal/httpapiEndpointContract.d.ts.map +1 -0
  43. package/dist/internal/httpapiEndpointContract.js +79 -0
  44. package/dist/internal/httpapiFileRoles.d.ts +67 -0
  45. package/dist/internal/httpapiFileRoles.d.ts.map +1 -0
  46. package/dist/internal/httpapiFileRoles.js +145 -0
  47. package/dist/internal/httpapiId.d.ts +30 -0
  48. package/dist/internal/httpapiId.d.ts.map +1 -0
  49. package/dist/internal/httpapiId.js +57 -0
  50. package/dist/internal/httpapiOpenApiConfig.d.ts +87 -0
  51. package/dist/internal/httpapiOpenApiConfig.d.ts.map +1 -0
  52. package/dist/internal/httpapiOpenApiConfig.js +144 -0
  53. package/dist/internal/httpapiSort.d.ts +16 -0
  54. package/dist/internal/httpapiSort.d.ts.map +1 -0
  55. package/dist/internal/httpapiSort.js +29 -0
  56. package/dist/internal/path.d.ts +16 -0
  57. package/dist/internal/path.d.ts.map +1 -0
  58. package/dist/internal/path.js +38 -0
  59. package/dist/internal/resolveConfig.d.ts +8 -0
  60. package/dist/internal/resolveConfig.d.ts.map +1 -0
  61. package/dist/internal/resolveConfig.js +13 -0
  62. package/dist/internal/routeIdentifiers.d.ts +18 -0
  63. package/dist/internal/routeIdentifiers.d.ts.map +1 -0
  64. package/dist/internal/routeIdentifiers.js +90 -0
  65. package/dist/internal/routeTypeNode.d.ts +45 -0
  66. package/dist/internal/routeTypeNode.d.ts.map +1 -0
  67. package/dist/internal/routeTypeNode.js +93 -0
  68. package/dist/internal/routerDescriptorTree.d.ts +110 -0
  69. package/dist/internal/routerDescriptorTree.d.ts.map +1 -0
  70. package/dist/internal/routerDescriptorTree.js +230 -0
  71. package/dist/internal/typeTargetBootstrap.d.ts +2 -0
  72. package/dist/internal/typeTargetBootstrap.d.ts.map +1 -0
  73. package/dist/internal/typeTargetBootstrap.js +23 -0
  74. package/dist/internal/typeTargetBootstrapHttpApi.d.ts +2 -0
  75. package/dist/internal/typeTargetBootstrapHttpApi.d.ts.map +1 -0
  76. package/dist/internal/typeTargetBootstrapHttpApi.js +21 -0
  77. package/dist/internal/typeTargetSpecs.d.ts +15 -0
  78. package/dist/internal/typeTargetSpecs.d.ts.map +1 -0
  79. package/dist/internal/typeTargetSpecs.js +32 -0
  80. package/dist/internal/validation.d.ts +12 -0
  81. package/dist/internal/validation.d.ts.map +1 -0
  82. package/dist/internal/validation.js +32 -0
  83. package/package.json +45 -0
  84. package/src/HttpApiVirtualModulePlugin.test.ts +1062 -0
  85. package/src/HttpApiVirtualModulePlugin.ts +376 -0
  86. package/src/RouterVirtualModulePlugin.test.ts +1254 -0
  87. package/src/RouterVirtualModulePlugin.ts +242 -0
  88. package/src/createTypeInfoApiSessionForApp.ts +57 -0
  89. package/src/defineApiHandler.test.ts +100 -0
  90. package/src/httpapi/defineApiHandler.ts +141 -0
  91. package/src/httpapiDescriptorTree.test.ts +124 -0
  92. package/src/httpapiEndpointContract.test.ts +160 -0
  93. package/src/httpapiFileRoles.test.ts +105 -0
  94. package/src/index.ts +40 -0
  95. package/src/internal/appConfigTypes.ts +12 -0
  96. package/src/internal/appLayerTypes.ts +79 -0
  97. package/src/internal/buildRouteDescriptors.ts +489 -0
  98. package/src/internal/emitHttpApiSource.ts +563 -0
  99. package/src/internal/emitRouterHelpers.ts +89 -0
  100. package/src/internal/emitRouterSource.ts +191 -0
  101. package/src/internal/extractHttpApiLiterals.ts +67 -0
  102. package/src/internal/httpapiDescriptorTree.ts +283 -0
  103. package/src/internal/httpapiEndpointContract.ts +110 -0
  104. package/src/internal/httpapiFileRoles.ts +204 -0
  105. package/src/internal/httpapiId.ts +78 -0
  106. package/src/internal/httpapiOpenApiConfig.ts +228 -0
  107. package/src/internal/httpapiSort.ts +39 -0
  108. package/src/internal/path.ts +46 -0
  109. package/src/internal/resolveConfig.ts +15 -0
  110. package/src/internal/routeIdentifiers.ts +93 -0
  111. package/src/internal/routeTypeNode.ts +120 -0
  112. package/src/internal/routerDescriptorTree.ts +366 -0
  113. package/src/internal/typeTargetBootstrap.ts +24 -0
  114. package/src/internal/typeTargetBootstrapHttpApi.ts +22 -0
  115. package/src/internal/typeTargetSpecs.ts +35 -0
  116. package/src/internal/validation.ts +46 -0
@@ -0,0 +1,489 @@
1
+ import { basename, dirname, join, relative } from "node:path";
2
+ import type {
3
+ ExportedTypeInfo,
4
+ TypeInfoApi,
5
+ TypeInfoFileSnapshot,
6
+ } from "@typed/virtual-modules";
7
+ import { stripScriptExtension, toPosixPath } from "./path.js";
8
+ import {
9
+ type CatchForm,
10
+ type DepsExportKind,
11
+ type RuntimeKind,
12
+ classifyCatchForm,
13
+ classifyDepsExport,
14
+ getCallableReturnType,
15
+ isCallableNode,
16
+ typeNodeExpectsRefSubjectParam,
17
+ typeNodeIsEffectOptionReturn,
18
+ typeNodeIsRouteCompatible,
19
+ typeNodeToRuntimeKind,
20
+ } from "./routeTypeNode.js";
21
+
22
+ export type ConcernKind = "guard" | "dependencies" | "layout" | "catch";
23
+
24
+ export type ComposedConcerns = {
25
+ readonly guard: readonly string[];
26
+ readonly dependencies: readonly string[];
27
+ readonly layout: readonly string[];
28
+ readonly catch: readonly string[];
29
+ };
30
+
31
+ /** Which concerns (layout, dependencies, catch, guard) are exported from the route file itself. */
32
+ export type InFileConcerns = Partial<Record<ConcernKind, true>>;
33
+
34
+ const ENTRYPOINT_EXPORTS = ["handler", "template", "default"] as const;
35
+ const COMPANION_SUFFIXES = [".guard.ts", ".dependencies.ts", ".layout.ts", ".catch.ts"] as const;
36
+ const DIRECTORY_COMPANIONS = new Set(["_guard.ts", "_dependencies.ts", "_layout.ts", "_catch.ts"]);
37
+
38
+ const GUARD_EXPORT_NAMES = ["default", "guard"] as const;
39
+ const CATCH_EXPORT_NAMES = ["catch", "catchFn"] as const;
40
+
41
+ type EntryPointExport = (typeof ENTRYPOINT_EXPORTS)[number];
42
+ type GuardExportName = (typeof GUARD_EXPORT_NAMES)[number];
43
+ type CatchExportName = (typeof CATCH_EXPORT_NAMES)[number];
44
+
45
+ function isEntryPointExport(name: string): name is EntryPointExport {
46
+ return (ENTRYPOINT_EXPORTS as readonly string[]).includes(name);
47
+ }
48
+
49
+ function isGuardExportName(name: string): name is GuardExportName {
50
+ return (GUARD_EXPORT_NAMES as readonly string[]).includes(name);
51
+ }
52
+
53
+ function isCatchExportName(name: string): name is CatchExportName {
54
+ return (CATCH_EXPORT_NAMES as readonly string[]).includes(name);
55
+ }
56
+
57
+ const COMPANION_KIND_TO_SUFFIX: Record<ConcernKind, string> = {
58
+ guard: ".guard.ts",
59
+ dependencies: ".dependencies.ts",
60
+ layout: ".layout.ts",
61
+ catch: ".catch.ts",
62
+ };
63
+
64
+ const COMPANION_KIND_TO_DIRECTORY_FILE: Record<ConcernKind, string> = {
65
+ guard: "_guard.ts",
66
+ dependencies: "_dependencies.ts",
67
+ layout: "_layout.ts",
68
+ catch: "_catch.ts",
69
+ };
70
+
71
+ export type RouteDescriptor = {
72
+ readonly filePath: string;
73
+ readonly entrypointExport: EntryPointExport;
74
+ readonly runtimeKind: RuntimeKind;
75
+ readonly entrypointIsFunction: boolean;
76
+ readonly entrypointExpectsRefSubject: boolean;
77
+ readonly composedConcerns: ComposedConcerns;
78
+ readonly inFileConcerns: InFileConcerns;
79
+ readonly routeTypeText: string;
80
+ };
81
+
82
+ export type RouteContractViolation = {
83
+ readonly code:
84
+ | "RVM-ROUTE-001"
85
+ | "RVM-ROUTE-002"
86
+ | "RVM-ENTRY-001"
87
+ | "RVM-ENTRY-002"
88
+ | "RVM-ENTRY-003"
89
+ | "RVM-LEAF-001"
90
+ | "RVM-AMBIGUOUS-001"
91
+ | "RVM-GUARD-001"
92
+ | "RVM-CATCH-001"
93
+ | "RVM-DEPS-001"
94
+ | "RVM-INFILE-COMPANION-001"
95
+ | "RVM-KIND-001";
96
+ readonly message: string;
97
+ };
98
+
99
+ export type GuardExportByPath = Readonly<Record<string, "default" | "guard">>;
100
+ export type CatchExportByPath = Readonly<Record<string, "catch" | "catchFn">>;
101
+ export type CatchFormByPath = Readonly<Record<string, CatchForm>>;
102
+ export type DepsFormByPath = Readonly<Record<string, DepsExportKind>>;
103
+
104
+ function isCompanionModulePath(absolutePath: string): boolean {
105
+ const fileName = basename(toPosixPath(absolutePath));
106
+ if (DIRECTORY_COMPANIONS.has(fileName)) return true;
107
+ return COMPANION_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
108
+ }
109
+
110
+ function listEntrypointExports(snapshot: TypeInfoFileSnapshot): readonly ExportedTypeInfo[] {
111
+ return snapshot.exports.filter((value) =>
112
+ ENTRYPOINT_EXPORTS.some((entrypointName) => entrypointName === value.name),
113
+ );
114
+ }
115
+
116
+ function isRouteExportCompatible(
117
+ routeExport: ExportedTypeInfo,
118
+ api: TypeInfoApi,
119
+ ): boolean {
120
+ return typeNodeIsRouteCompatible(routeExport.type, api);
121
+ }
122
+
123
+ function classifyEntrypointKind(
124
+ entrypoint: ExportedTypeInfo,
125
+ api: TypeInfoApi,
126
+ ): RuntimeKind {
127
+ const { type } = entrypoint;
128
+ const nodeForKind = isCallableNode(type) ? (getCallableReturnType(type) ?? type) : type;
129
+ return typeNodeToRuntimeKind(nodeForKind, api);
130
+ }
131
+
132
+ function getEntryPointName(
133
+ entrypoint: ExportedTypeInfo,
134
+ relPath: string,
135
+ ): { ok: true; value: EntryPointExport } | { ok: false; violation: RouteContractViolation } {
136
+ if (!isEntryPointExport(entrypoint.name)) {
137
+ return {
138
+ ok: false,
139
+ violation: {
140
+ code: "RVM-ENTRY-003",
141
+ message: `invalid entrypoint export name ${JSON.stringify(entrypoint.name)} in ${relPath}`,
142
+ },
143
+ };
144
+ }
145
+ return { ok: true, value: entrypoint.name };
146
+ }
147
+
148
+ function resolveComposedConcernsForLeaf(
149
+ leafFilePath: string,
150
+ existingPaths: ReadonlySet<string>,
151
+ ): ComposedConcerns {
152
+ const leafDir = dirname(leafFilePath);
153
+ const leafBaseName = basename(stripScriptExtension(leafFilePath));
154
+ const ancestorDirs: string[] = [""];
155
+ if (leafDir !== "." && leafDir !== "") {
156
+ const segments = leafDir.split("/").filter(Boolean);
157
+ let acc = "";
158
+ for (const seg of segments) {
159
+ acc = acc ? `${acc}/${seg}` : seg;
160
+ ancestorDirs.push(acc);
161
+ }
162
+ }
163
+
164
+ const collectForKind = (kind: ConcernKind): string[] => {
165
+ const result: string[] = [];
166
+ for (const d of [...ancestorDirs].reverse()) {
167
+ const dirPath =
168
+ d === "."
169
+ ? COMPANION_KIND_TO_DIRECTORY_FILE[kind]
170
+ : join(d, COMPANION_KIND_TO_DIRECTORY_FILE[kind]);
171
+ const normal = toPosixPath(dirPath);
172
+ if (existingPaths.has(normal)) result.push(normal);
173
+ }
174
+ const siblingPath =
175
+ leafDir === "."
176
+ ? `${leafBaseName}${COMPANION_KIND_TO_SUFFIX[kind]}`
177
+ : toPosixPath(join(leafDir, `${leafBaseName}${COMPANION_KIND_TO_SUFFIX[kind]}`));
178
+ if (existingPaths.has(siblingPath)) result.push(siblingPath);
179
+ return result;
180
+ };
181
+
182
+ return {
183
+ guard: collectForKind("guard"),
184
+ dependencies: collectForKind("dependencies"),
185
+ layout: collectForKind("layout"),
186
+ catch: collectForKind("catch"),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Sibling companion path for a leaf file (e.g. "nested/Y.tsx" + "layout" → "nested/Y.layout.ts").
192
+ */
193
+ export function siblingCompanionPath(leafFilePath: string, kind: ConcernKind): string {
194
+ const dir = dirname(leafFilePath);
195
+ const base = basename(stripScriptExtension(leafFilePath));
196
+ const file = kind === "dependencies" ? `${base}.dependencies.ts` : `${base}.${kind}.ts`;
197
+ return dir ? toPosixPath(join(dir, file)) : file;
198
+ }
199
+
200
+ /**
201
+ * Build route descriptors and validate guards, catches, and dependencies from type info snapshots.
202
+ */
203
+ export function buildRouteDescriptors(
204
+ snapshots: readonly TypeInfoFileSnapshot[],
205
+ baseDir: string,
206
+ api: TypeInfoApi,
207
+ ): {
208
+ readonly descriptors: readonly RouteDescriptor[];
209
+ readonly violations: readonly RouteContractViolation[];
210
+ readonly guardExportByPath: GuardExportByPath;
211
+ readonly catchExportByPath: CatchExportByPath;
212
+ readonly catchFormByPath: CatchFormByPath;
213
+ readonly depsFormByPath: DepsFormByPath;
214
+ } {
215
+ const descriptors: RouteDescriptor[] = [];
216
+ const violations: RouteContractViolation[] = [];
217
+ const existingPaths = new Set(snapshots.map((s) => toPosixPath(relative(baseDir, s.filePath))));
218
+
219
+ for (const snapshot of snapshots) {
220
+ if (isCompanionModulePath(snapshot.filePath)) continue;
221
+ const entrypoints = listEntrypointExports(snapshot);
222
+ const routeExport = snapshot.exports.find((value) => value.name === "route");
223
+
224
+ if (!routeExport) {
225
+ if (entrypoints.length > 0) {
226
+ violations.push({
227
+ code: "RVM-ROUTE-001",
228
+ message: `missing "route" export in ${toPosixPath(relative(baseDir, snapshot.filePath))}`,
229
+ });
230
+ }
231
+ continue;
232
+ }
233
+
234
+ if (!isRouteExportCompatible(routeExport, api)) {
235
+ violations.push({
236
+ code: "RVM-ROUTE-002",
237
+ message: `route export is not structurally compatible with Route in ${toPosixPath(relative(baseDir, snapshot.filePath))}`,
238
+ });
239
+ continue;
240
+ }
241
+
242
+ if (entrypoints.length === 0) {
243
+ violations.push({
244
+ code: "RVM-ENTRY-001",
245
+ message: `expected one of handler|template|default in ${toPosixPath(relative(baseDir, snapshot.filePath))}`,
246
+ });
247
+ continue;
248
+ }
249
+
250
+ if (entrypoints.length > 1) {
251
+ violations.push({
252
+ code: "RVM-ENTRY-002",
253
+ message: `multiple entrypoint exports found in ${toPosixPath(relative(baseDir, snapshot.filePath))}`,
254
+ });
255
+ continue;
256
+ }
257
+
258
+ const entrypoint = entrypoints[0]!;
259
+ const relPath = toPosixPath(relative(baseDir, snapshot.filePath));
260
+ const entrypointNameResult = getEntryPointName(entrypoint, relPath);
261
+ if (!entrypointNameResult.ok) {
262
+ violations.push(entrypointNameResult.violation);
263
+ continue;
264
+ }
265
+ const runtimeKind = classifyEntrypointKind(entrypoint, api);
266
+ if (runtimeKind === "unknown") {
267
+ violations.push({
268
+ code: "RVM-KIND-001",
269
+ message: `handler/template/default runtime kind could not be determined (type targets missing). Ensure route files import from @typed/fx, effect, etc. in ${relPath}`,
270
+ });
271
+ continue;
272
+ }
273
+ const entrypointIsFunction = isCallableNode(entrypoint.type);
274
+ const entrypointExpectsRefSubject =
275
+ entrypointIsFunction &&
276
+ typeNodeExpectsRefSubjectParam(entrypoint.type, api);
277
+ const composedConcerns = resolveComposedConcernsForLeaf(relPath, existingPaths);
278
+ const inFileConcerns: InFileConcerns = {};
279
+ for (const name of ["layout", "dependencies", "catch", "guard"] as const) {
280
+ if (snapshot.exports.some((e) => e.name === name)) inFileConcerns[name] = true;
281
+ }
282
+ descriptors.push({
283
+ filePath: relPath,
284
+ entrypointExport: entrypointNameResult.value,
285
+ runtimeKind,
286
+ entrypointIsFunction,
287
+ entrypointExpectsRefSubject,
288
+ composedConcerns,
289
+ inFileConcerns,
290
+ routeTypeText: routeExport.type.text,
291
+ });
292
+ }
293
+
294
+ const sortedDescriptors = [...descriptors].sort((left, right) =>
295
+ left.filePath.localeCompare(right.filePath),
296
+ );
297
+
298
+ const seenRouteIdentity = new Map<string, string>();
299
+ const ambiguousViolations: RouteContractViolation[] = [];
300
+ const dedupedDescriptors: typeof sortedDescriptors = [];
301
+ for (const d of sortedDescriptors) {
302
+ const typeKey = d.routeTypeText.replace(/\s+/g, " ").trim();
303
+ const key = `${d.filePath}:${typeKey}`;
304
+ const firstSeen = seenRouteIdentity.get(key);
305
+ if (firstSeen !== undefined) {
306
+ ambiguousViolations.push({
307
+ code: "RVM-AMBIGUOUS-001",
308
+ message: `ambiguous route: same type as ${firstSeen}, also in ${d.filePath}`,
309
+ });
310
+ continue;
311
+ }
312
+ seenRouteIdentity.set(key, d.filePath);
313
+ dedupedDescriptors.push(d);
314
+ }
315
+
316
+ const guardViolations: RouteContractViolation[] = [];
317
+ const guardExportByPath: Record<string, "default" | "guard"> = {};
318
+ const guardPaths = new Set<string>();
319
+ for (const d of dedupedDescriptors) {
320
+ for (const p of d.composedConcerns.guard) guardPaths.add(p);
321
+ }
322
+ for (const relPath of guardPaths) {
323
+ const snapshot = snapshots.find((s) => toPosixPath(relative(baseDir, s.filePath)) === relPath);
324
+ if (!snapshot) continue;
325
+ const guardExport =
326
+ snapshot.exports.find((e) => e.name === GUARD_EXPORT_NAMES[0]) ??
327
+ snapshot.exports.find((e) => e.name === GUARD_EXPORT_NAMES[1]);
328
+ if (!guardExport) {
329
+ guardViolations.push({
330
+ code: "RVM-GUARD-001",
331
+ message: `guard file must export "guard" or default: ${relPath}`,
332
+ });
333
+ continue;
334
+ }
335
+ if (!isCallableNode(guardExport.type)) {
336
+ guardViolations.push({
337
+ code: "RVM-GUARD-001",
338
+ message: `guard export must be a function (Effect<Option<*>, *, *>): ${relPath}`,
339
+ });
340
+ continue;
341
+ }
342
+ if (!typeNodeIsEffectOptionReturn(guardExport.type, api)) {
343
+ guardViolations.push({
344
+ code: "RVM-GUARD-001",
345
+ message: `guard return type must be Effect<Option<*>, *, *>: ${relPath}`,
346
+ });
347
+ continue;
348
+ }
349
+ if (!isGuardExportName(guardExport.name)) {
350
+ guardViolations.push({
351
+ code: "RVM-GUARD-001",
352
+ message: `guard export name ${JSON.stringify(guardExport.name)} not in [guard, default]: ${relPath}`,
353
+ });
354
+ continue;
355
+ }
356
+ guardExportByPath[relPath] = guardExport.name;
357
+ }
358
+
359
+ const catchExportByPath: Record<string, "catch" | "catchFn"> = {};
360
+ const catchFormByPath: Record<string, CatchForm> = {};
361
+ const depsFormByPath: Record<string, DepsExportKind> = {};
362
+ const catchPaths = new Set<string>();
363
+ const catchViolations: RouteContractViolation[] = [];
364
+ for (const d of dedupedDescriptors) {
365
+ for (const p of d.composedConcerns.catch) catchPaths.add(p);
366
+ }
367
+ for (const relPath of catchPaths) {
368
+ const snapshot = snapshots.find((s) => toPosixPath(relative(baseDir, s.filePath)) === relPath);
369
+ if (!snapshot) continue;
370
+ const catchExport =
371
+ snapshot.exports.find((e) => e.name === CATCH_EXPORT_NAMES[0]) ??
372
+ snapshot.exports.find((e) => e.name === CATCH_EXPORT_NAMES[1]);
373
+ if (!catchExport) {
374
+ catchViolations.push({
375
+ code: "RVM-CATCH-001",
376
+ message: `catch file must export "catch" or "catchFn": ${relPath}`,
377
+ });
378
+ continue;
379
+ }
380
+ if (!isCatchExportName(catchExport.name)) {
381
+ catchViolations.push({
382
+ code: "RVM-CATCH-001",
383
+ message: `catch export name ${JSON.stringify(catchExport.name)} not in [catch, catchFn]: ${relPath}`,
384
+ });
385
+ continue;
386
+ }
387
+ const catchForm = classifyCatchForm(catchExport.type, api);
388
+ if (catchForm.returnKind === "unknown") {
389
+ catchViolations.push({
390
+ code: "RVM-KIND-001",
391
+ message: `catch return kind could not be determined (type targets missing): ${relPath}`,
392
+ });
393
+ continue;
394
+ }
395
+ catchExportByPath[relPath] = catchExport.name;
396
+ catchFormByPath[relPath] = catchForm;
397
+ }
398
+
399
+ for (const d of dedupedDescriptors) {
400
+ if (!d.inFileConcerns.catch) continue;
401
+ const snapshot = snapshots.find(
402
+ (s) => toPosixPath(relative(baseDir, s.filePath)) === d.filePath,
403
+ );
404
+ if (!snapshot) continue;
405
+ const catchExport =
406
+ snapshot.exports.find((e) => e.name === CATCH_EXPORT_NAMES[0]) ??
407
+ snapshot.exports.find((e) => e.name === CATCH_EXPORT_NAMES[1]);
408
+ if (catchExport) {
409
+ if (!isCatchExportName(catchExport.name)) {
410
+ catchViolations.push({
411
+ code: "RVM-CATCH-001",
412
+ message: `catch export name ${JSON.stringify(catchExport.name)} not in [catch, catchFn]: ${d.filePath}`,
413
+ });
414
+ continue;
415
+ }
416
+ const catchForm = classifyCatchForm(catchExport.type, api);
417
+ if (catchForm.returnKind === "unknown") {
418
+ catchViolations.push({
419
+ code: "RVM-KIND-001",
420
+ message: `catch return kind could not be determined (type targets missing): ${d.filePath}`,
421
+ });
422
+ } else {
423
+ catchExportByPath[d.filePath] = catchExport.name;
424
+ catchFormByPath[d.filePath] = catchForm;
425
+ }
426
+ }
427
+ }
428
+
429
+ const depsPaths = new Set<string>();
430
+ for (const d of dedupedDescriptors) {
431
+ for (const p of d.composedConcerns.dependencies) {
432
+ if (basename(p).startsWith("_")) depsPaths.add(p);
433
+ }
434
+ }
435
+ const depsViolations: RouteContractViolation[] = [];
436
+ for (const relPath of depsPaths) {
437
+ const snapshot = snapshots.find((s) => toPosixPath(relative(baseDir, s.filePath)) === relPath);
438
+ if (!snapshot) continue;
439
+ const defaultExport = snapshot.exports.find((e) => e.name === "default");
440
+ if (!defaultExport) {
441
+ depsViolations.push({
442
+ code: "RVM-DEPS-001",
443
+ message: `${relPath} is used as directory dependencies but has no default export. Export a default of type Layer, ServiceMap, or Array.`,
444
+ });
445
+ continue;
446
+ }
447
+ const kind = classifyDepsExport(defaultExport.type, api);
448
+ if (kind === "unknown") {
449
+ depsViolations.push({
450
+ code: "RVM-DEPS-001",
451
+ message: `${relPath} default export type could not be determined. Must be Layer, ServiceMap, or Array.`,
452
+ });
453
+ continue;
454
+ }
455
+ depsFormByPath[relPath] = kind;
456
+ }
457
+
458
+ const infileCompanionViolations: RouteContractViolation[] = [];
459
+ for (const d of dedupedDescriptors) {
460
+ for (const kind of ["layout", "dependencies", "catch", "guard"] as const) {
461
+ if (!d.inFileConcerns[kind]) continue;
462
+ const siblingPath = siblingCompanionPath(d.filePath, kind);
463
+ if (d.composedConcerns[kind].includes(siblingPath)) {
464
+ infileCompanionViolations.push({
465
+ code: "RVM-INFILE-COMPANION-001",
466
+ message: `${d.filePath} exports "${kind}" in-file and has companion ${siblingPath}; in-file wins but this is ambiguous. Remove one.`,
467
+ });
468
+ }
469
+ }
470
+ }
471
+
472
+ const allViolations = [
473
+ ...violations,
474
+ ...ambiguousViolations,
475
+ ...guardViolations,
476
+ ...catchViolations,
477
+ ...depsViolations,
478
+ ...infileCompanionViolations,
479
+ ].sort((left, right) => left.message.localeCompare(right.message));
480
+
481
+ return {
482
+ descriptors: dedupedDescriptors,
483
+ violations: allViolations,
484
+ guardExportByPath,
485
+ catchExportByPath,
486
+ catchFormByPath,
487
+ depsFormByPath,
488
+ };
489
+ }