@ontrails/adapter-kit 0.2.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/catalog.ts ADDED
@@ -0,0 +1,880 @@
1
+ /**
2
+ * Read-only adapter target catalog derivation.
3
+ *
4
+ * Owner packages author the few adapter facts package metadata cannot derive.
5
+ * Tooling consumes those facts; runtime adapters must never import this
6
+ * internal tooling package.
7
+ */
8
+
9
+ import { existsSync, realpathSync, statSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+
12
+ import { listWorkspacePackages } from '@ontrails/core';
13
+
14
+ import {
15
+ adapterSourceExportKind,
16
+ adapterSourceExportKindHasType,
17
+ adapterSourceExportKindHasValue,
18
+ } from './source.js';
19
+
20
+ export const adapterTargetPlacements = ['extracted', 'subpath'] as const;
21
+
22
+ export type AdapterTargetPlacementValue =
23
+ (typeof adapterTargetPlacements)[number];
24
+
25
+ export type AdapterTargetPlacement = AdapterTargetPlacementValue;
26
+
27
+ export interface AdapterTargetConformanceManifest {
28
+ readonly adapterType: string;
29
+ readonly casesFactory: string;
30
+ readonly runner: string;
31
+ }
32
+
33
+ export interface AdapterTargetManifestEntry {
34
+ readonly conformance?: AdapterTargetConformanceManifest | undefined;
35
+ readonly placements: readonly AdapterTargetPlacement[];
36
+ readonly supportImport?: string | undefined;
37
+ readonly testingImport?: string | undefined;
38
+ }
39
+
40
+ export interface AdapterTargetCatalogEntry extends AdapterTargetManifestEntry {
41
+ readonly key: string;
42
+ readonly ownerPackage: string;
43
+ readonly packageJsonPath: string;
44
+ readonly packageRoot: string;
45
+ readonly supportExportTarget?: string | undefined;
46
+ readonly target: string;
47
+ readonly testingExportTarget?: string | undefined;
48
+ }
49
+
50
+ export type AdapterTargetCatalogDiagnosticCode =
51
+ | 'duplicate-adapter-target'
52
+ | 'invalid-adapter-target'
53
+ | 'invalid-adapter-targets'
54
+ | 'invalid-conformance'
55
+ | 'invalid-import'
56
+ | 'invalid-placement';
57
+
58
+ export interface AdapterTargetCatalogDiagnostic {
59
+ readonly code: AdapterTargetCatalogDiagnosticCode;
60
+ readonly message: string;
61
+ readonly packageJsonPath: string;
62
+ readonly packageName?: string | undefined;
63
+ readonly target?: string | undefined;
64
+ }
65
+
66
+ export interface AdapterTargetCatalog {
67
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
68
+ readonly targets: readonly AdapterTargetCatalogEntry[];
69
+ }
70
+
71
+ export interface AdapterTargetPackageManifest {
72
+ readonly exports?: unknown;
73
+ readonly name?: unknown;
74
+ readonly trails?: unknown;
75
+ }
76
+
77
+ type NamedAdapterTargetPackageManifest = AdapterTargetPackageManifest & {
78
+ readonly name: string;
79
+ };
80
+
81
+ export interface AdapterTargetParseContext {
82
+ readonly blockedExportSpecifiers: readonly string[];
83
+ readonly exportTargets: Readonly<Record<string, string>>;
84
+ readonly packageJsonPath: string;
85
+ readonly packageName: string;
86
+ readonly packageRoot: string;
87
+ }
88
+
89
+ interface ParsedCatalogTarget {
90
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
91
+ readonly targetEntry?: AdapterTargetCatalogEntry | undefined;
92
+ }
93
+
94
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
95
+ Boolean(value && typeof value === 'object' && !Array.isArray(value));
96
+
97
+ const targetIdPattern = /^[a-z][a-z0-9-]*$/u;
98
+ const exportIdentifierPattern = /^[A-Za-z_$][\w$]*$/u;
99
+
100
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
101
+
102
+ const normalizeRealPath = (path: string): string => {
103
+ try {
104
+ return normalizePath(realpathSync(path));
105
+ } catch {
106
+ return normalizePath(resolve(path));
107
+ }
108
+ };
109
+
110
+ const pathIsFile = (path: string): boolean => {
111
+ try {
112
+ return statSync(path).isFile();
113
+ } catch {
114
+ return false;
115
+ }
116
+ };
117
+
118
+ const exportConditions = new Set([
119
+ 'bun',
120
+ 'node',
121
+ 'node-addons',
122
+ 'module-sync',
123
+ 'import',
124
+ 'default',
125
+ ]);
126
+
127
+ type ResolvedExportTarget =
128
+ | { readonly kind: 'target'; readonly target: string }
129
+ | { readonly kind: 'blocked' };
130
+
131
+ const packageExportSegmentIsSafe = (segment: string): boolean => {
132
+ if (segment.length === 0) {
133
+ return false;
134
+ }
135
+ let decoded: string;
136
+ try {
137
+ decoded = decodeURIComponent(segment);
138
+ } catch {
139
+ return false;
140
+ }
141
+ return (
142
+ decoded !== '.' &&
143
+ decoded !== '..' &&
144
+ decoded.toLowerCase() !== 'node_modules' &&
145
+ !decoded.includes('/') &&
146
+ !decoded.includes('\\')
147
+ );
148
+ };
149
+
150
+ const exportTargetIsSafe = (target: string): boolean =>
151
+ target.startsWith('./') &&
152
+ !target.includes('\\') &&
153
+ target.slice(2).split('/').every(packageExportSegmentIsSafe);
154
+
155
+ const resolveExportTarget = (
156
+ target: unknown,
157
+ depth = 0
158
+ ): ResolvedExportTarget | undefined => {
159
+ if (typeof target === 'string') {
160
+ return { kind: 'target', target };
161
+ }
162
+ if (target === null) {
163
+ return { kind: 'blocked' };
164
+ }
165
+ if (Array.isArray(target)) {
166
+ if (depth > 8) {
167
+ return undefined;
168
+ }
169
+ for (const targetEntry of target) {
170
+ const resolvedTarget = resolveExportTarget(targetEntry, depth + 1);
171
+ if (
172
+ resolvedTarget?.kind === 'target' &&
173
+ exportTargetIsSafe(resolvedTarget.target)
174
+ ) {
175
+ return resolvedTarget;
176
+ }
177
+ }
178
+ return { kind: 'blocked' };
179
+ }
180
+ if (!isRecord(target) || depth > 8) {
181
+ return undefined;
182
+ }
183
+
184
+ for (const [condition, conditionTarget] of Object.entries(target)) {
185
+ if (!exportConditions.has(condition)) {
186
+ continue;
187
+ }
188
+ const resolvedTarget = resolveExportTarget(conditionTarget, depth + 1);
189
+ if (resolvedTarget) {
190
+ return resolvedTarget;
191
+ }
192
+ }
193
+ return undefined;
194
+ };
195
+
196
+ const exportSpecifierFromKey = (
197
+ packageName: string,
198
+ key: string
199
+ ): string | undefined => {
200
+ if (key === '.') {
201
+ return packageName;
202
+ }
203
+ if (!key.startsWith('./')) {
204
+ return undefined;
205
+ }
206
+ return `${packageName}/${key.slice(2)}`;
207
+ };
208
+
209
+ const wildcardCaptureIsSafe = (capture: string): boolean =>
210
+ !capture.includes('\\') &&
211
+ capture.split('/').every(packageExportSegmentIsSafe);
212
+
213
+ const wildcardCapture = (
214
+ pattern: string,
215
+ value: string
216
+ ): string | undefined => {
217
+ const star = pattern.indexOf('*');
218
+ if (star === -1) {
219
+ return undefined;
220
+ }
221
+
222
+ const prefix = pattern.slice(0, star);
223
+ const suffix = pattern.slice(star + 1);
224
+ if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
225
+ return undefined;
226
+ }
227
+
228
+ const capture = value.slice(prefix.length, value.length - suffix.length);
229
+ return capture.length > 0 && wildcardCaptureIsSafe(capture)
230
+ ? capture
231
+ : undefined;
232
+ };
233
+
234
+ const applyWildcardCapture = (targetPattern: string, capture: string): string =>
235
+ targetPattern.replaceAll('*', capture);
236
+
237
+ type WildcardExportCandidate =
238
+ | {
239
+ readonly kind: 'target';
240
+ readonly pattern: string;
241
+ readonly target: string;
242
+ }
243
+ | { readonly kind: 'blocked'; readonly pattern: string };
244
+
245
+ /**
246
+ * Order two wildcard export keys by Node's package-exports precedence: the
247
+ * longer prefix before the wildcard wins first, then the longer total key. This
248
+ * mirrors Node's `patternKeyCompare`, so equal-total-length patterns (for
249
+ * example a leading-wildcard key versus a trailing-wildcard key) resolve the
250
+ * way the runtime loader would.
251
+ */
252
+ const patternKeyCompare = (left: string, right: string): number => {
253
+ const leftBase = left.indexOf('*') + 1;
254
+ const rightBase = right.indexOf('*') + 1;
255
+ if (leftBase !== rightBase) {
256
+ return rightBase - leftBase;
257
+ }
258
+ return right.length - left.length;
259
+ };
260
+
261
+ const resolveExportTargetForImport = (
262
+ context: AdapterTargetParseContext,
263
+ importSpecifier: string
264
+ ): string | undefined => {
265
+ // Exact `null` exclusions block the subpath before any wildcard fallback.
266
+ if (context.blockedExportSpecifiers.includes(importSpecifier)) {
267
+ return undefined;
268
+ }
269
+
270
+ const exactTarget = context.exportTargets[importSpecifier];
271
+ if (exactTarget) {
272
+ return exactTarget;
273
+ }
274
+
275
+ // Match the most specific wildcard key using Node's exports precedence
276
+ // (longer prefix before the wildcard first, then longer total key). A blocked
277
+ // pattern that is more specific than a broader target pattern must reject the
278
+ // import instead of resolving it.
279
+ const candidates: WildcardExportCandidate[] = [
280
+ ...Object.entries(context.exportTargets)
281
+ .filter(([specifier]) => specifier.includes('*'))
282
+ .map(
283
+ ([pattern, target]): WildcardExportCandidate => ({
284
+ kind: 'target',
285
+ pattern,
286
+ target,
287
+ })
288
+ ),
289
+ ...context.blockedExportSpecifiers
290
+ .filter((specifier) => specifier.includes('*'))
291
+ .map(
292
+ (pattern): WildcardExportCandidate => ({ kind: 'blocked', pattern })
293
+ ),
294
+ ].toSorted((left, right) => patternKeyCompare(left.pattern, right.pattern));
295
+
296
+ for (const candidate of candidates) {
297
+ const capture = wildcardCapture(candidate.pattern, importSpecifier);
298
+ if (capture === undefined) {
299
+ continue;
300
+ }
301
+ return candidate.kind === 'blocked'
302
+ ? undefined
303
+ : applyWildcardCapture(candidate.target, capture);
304
+ }
305
+
306
+ return undefined;
307
+ };
308
+
309
+ interface NormalizedExportTargets {
310
+ readonly blocked: readonly string[];
311
+ readonly targets: Readonly<Record<string, string>>;
312
+ }
313
+
314
+ const normalizeExportTargets = (
315
+ packageRoot: string,
316
+ packageName: string,
317
+ exportsValue: unknown
318
+ ): NormalizedExportTargets => {
319
+ if (!isRecord(exportsValue)) {
320
+ return { blocked: [], targets: {} };
321
+ }
322
+
323
+ const targets: Record<string, string> = {};
324
+ const blocked: string[] = [];
325
+ for (const [key, value] of Object.entries(exportsValue)) {
326
+ const specifier = exportSpecifierFromKey(packageName, key);
327
+ if (!specifier) {
328
+ continue;
329
+ }
330
+ const resolvedTarget = resolveExportTarget(value);
331
+ if (resolvedTarget?.kind === 'target') {
332
+ if (!exportTargetIsSafe(resolvedTarget.target)) {
333
+ blocked.push(specifier);
334
+ continue;
335
+ }
336
+ targets[specifier] = normalizeRealPath(
337
+ join(packageRoot, resolvedTarget.target)
338
+ );
339
+ continue;
340
+ }
341
+ // A declared export key that does not resolve to a runtime target (an
342
+ // explicit `null` exclusion, or a conditions object with no runtime
343
+ // condition such as a `types`-only entry) blocks the subpath. Node selects
344
+ // the most specific matching key and reports the subpath as not exported, so
345
+ // it must not fall through to a broader wildcard.
346
+ blocked.push(specifier);
347
+ }
348
+ return { blocked, targets };
349
+ };
350
+
351
+ const diagnostic = (
352
+ context: AdapterTargetParseContext,
353
+ code: AdapterTargetCatalogDiagnosticCode,
354
+ message: string,
355
+ target?: string
356
+ ): AdapterTargetCatalogDiagnostic => ({
357
+ code,
358
+ message,
359
+ packageJsonPath: context.packageJsonPath,
360
+ packageName: context.packageName,
361
+ ...(target === undefined ? {} : { target }),
362
+ });
363
+
364
+ const targetDiagnostic = (
365
+ entry: AdapterTargetCatalogEntry,
366
+ code: AdapterTargetCatalogDiagnosticCode,
367
+ message: string
368
+ ): AdapterTargetCatalogDiagnostic => ({
369
+ code,
370
+ message,
371
+ packageJsonPath: entry.packageJsonPath,
372
+ packageName: entry.ownerPackage,
373
+ target: entry.target,
374
+ });
375
+
376
+ const rejectDuplicateTargetIds = (
377
+ targets: readonly AdapterTargetCatalogEntry[]
378
+ ): {
379
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
380
+ readonly targets: readonly AdapterTargetCatalogEntry[];
381
+ } => {
382
+ const entriesByTarget = new Map<string, AdapterTargetCatalogEntry[]>();
383
+ for (const entry of targets) {
384
+ entriesByTarget.set(entry.target, [
385
+ ...(entriesByTarget.get(entry.target) ?? []),
386
+ entry,
387
+ ]);
388
+ }
389
+
390
+ const duplicateTargets = new Set(
391
+ [...entriesByTarget.entries()]
392
+ .filter(([, entries]) => entries.length > 1)
393
+ .map(([target]) => target)
394
+ );
395
+
396
+ return {
397
+ diagnostics: [...entriesByTarget.values()]
398
+ .filter((entries) => entries.length > 1)
399
+ .flatMap((entries) =>
400
+ entries.map((entry) =>
401
+ targetDiagnostic(
402
+ entry,
403
+ 'duplicate-adapter-target',
404
+ `Adapter target "${entry.target}" is declared by multiple owner packages; target ids must be globally unique until adapter metadata can select an owner.`
405
+ )
406
+ )
407
+ ),
408
+ targets: targets.filter((entry) => !duplicateTargets.has(entry.target)),
409
+ };
410
+ };
411
+
412
+ const isAdapterTargetPlacement = (
413
+ value: unknown
414
+ ): value is AdapterTargetPlacement =>
415
+ typeof value === 'string' &&
416
+ adapterTargetPlacements.includes(value as AdapterTargetPlacement);
417
+
418
+ const normalizePlacements = (
419
+ value: unknown,
420
+ context: AdapterTargetParseContext,
421
+ target: string
422
+ ): {
423
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
424
+ readonly placements: readonly AdapterTargetPlacement[];
425
+ } => {
426
+ if (!Array.isArray(value)) {
427
+ return {
428
+ diagnostics: [
429
+ diagnostic(
430
+ context,
431
+ 'invalid-placement',
432
+ `Adapter target "${target}" must declare placements as an array.`,
433
+ target
434
+ ),
435
+ ],
436
+ placements: [],
437
+ };
438
+ }
439
+
440
+ const diagnostics: AdapterTargetCatalogDiagnostic[] = [];
441
+ if (value.length === 0) {
442
+ diagnostics.push(
443
+ diagnostic(
444
+ context,
445
+ 'invalid-placement',
446
+ `Adapter target "${target}" must declare at least one placement.`,
447
+ target
448
+ )
449
+ );
450
+ }
451
+
452
+ const placements = new Set<AdapterTargetPlacement>();
453
+ for (const placement of value) {
454
+ if (isAdapterTargetPlacement(placement)) {
455
+ placements.add(placement);
456
+ continue;
457
+ }
458
+ diagnostics.push(
459
+ diagnostic(
460
+ context,
461
+ 'invalid-placement',
462
+ `Adapter target "${target}" has unsupported placement ${JSON.stringify(placement)}.`,
463
+ target
464
+ )
465
+ );
466
+ }
467
+
468
+ return {
469
+ diagnostics,
470
+ placements: [...placements].toSorted(),
471
+ };
472
+ };
473
+
474
+ const normalizeOptionalImport = (
475
+ value: unknown,
476
+ field: 'supportImport' | 'testingImport',
477
+ context: AdapterTargetParseContext,
478
+ target: string
479
+ ): {
480
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
481
+ readonly importSpecifier?: string | undefined;
482
+ } => {
483
+ if (value === undefined) {
484
+ return { diagnostics: [] };
485
+ }
486
+ if (typeof value === 'string' && value.length > 0) {
487
+ if (!value.startsWith(`${context.packageName}/`)) {
488
+ return {
489
+ diagnostics: [
490
+ diagnostic(
491
+ context,
492
+ 'invalid-import',
493
+ `Adapter target "${target}" must declare ${field} as an owner package subpath inside ${context.packageName}.`,
494
+ target
495
+ ),
496
+ ],
497
+ };
498
+ }
499
+
500
+ return { diagnostics: [], importSpecifier: value };
501
+ }
502
+ return {
503
+ diagnostics: [
504
+ diagnostic(
505
+ context,
506
+ 'invalid-import',
507
+ `Adapter target "${target}" must declare ${field} as a non-empty string when present.`,
508
+ target
509
+ ),
510
+ ],
511
+ };
512
+ };
513
+
514
+ const normalizeConformance = (
515
+ value: unknown,
516
+ context: AdapterTargetParseContext,
517
+ target: string,
518
+ hasTestingImport: boolean
519
+ ): {
520
+ readonly diagnostics: readonly AdapterTargetCatalogDiagnostic[];
521
+ readonly conformance?: AdapterTargetConformanceManifest | undefined;
522
+ } => {
523
+ if (value === undefined) {
524
+ return { diagnostics: [] };
525
+ }
526
+
527
+ if (!isRecord(value)) {
528
+ return {
529
+ diagnostics: [
530
+ diagnostic(
531
+ context,
532
+ 'invalid-conformance',
533
+ `Adapter target "${target}" must declare conformance as an object when present.`,
534
+ target
535
+ ),
536
+ ],
537
+ };
538
+ }
539
+
540
+ const diagnostics: AdapterTargetCatalogDiagnostic[] = [];
541
+ if (!hasTestingImport) {
542
+ diagnostics.push(
543
+ diagnostic(
544
+ context,
545
+ 'invalid-conformance',
546
+ `Adapter target "${target}" must declare testingImport before conformance helpers.`,
547
+ target
548
+ )
549
+ );
550
+ }
551
+
552
+ const conformance: {
553
+ adapterType?: string | undefined;
554
+ casesFactory?: string | undefined;
555
+ runner?: string | undefined;
556
+ } = {};
557
+ for (const field of ['adapterType', 'casesFactory', 'runner'] as const) {
558
+ const fieldValue = value[field];
559
+ if (
560
+ typeof fieldValue === 'string' &&
561
+ exportIdentifierPattern.test(fieldValue)
562
+ ) {
563
+ conformance[field] = fieldValue;
564
+ continue;
565
+ }
566
+ diagnostics.push(
567
+ diagnostic(
568
+ context,
569
+ 'invalid-conformance',
570
+ `Adapter target "${target}" must declare conformance.${field} as a valid named export.`,
571
+ target
572
+ )
573
+ );
574
+ }
575
+
576
+ if (diagnostics.length > 0) {
577
+ return { diagnostics };
578
+ }
579
+
580
+ return {
581
+ conformance: conformance as AdapterTargetConformanceManifest,
582
+ diagnostics: [],
583
+ };
584
+ };
585
+
586
+ const conformanceExportDiagnostics = (
587
+ context: AdapterTargetParseContext,
588
+ target: string,
589
+ conformance: AdapterTargetConformanceManifest,
590
+ testingExportTarget: string
591
+ ): readonly AdapterTargetCatalogDiagnostic[] => {
592
+ if (!existsSync(testingExportTarget)) {
593
+ return [
594
+ diagnostic(
595
+ context,
596
+ 'invalid-conformance',
597
+ `Adapter target "${target}" declares conformance helpers, but the testing export source could not be read.`,
598
+ target
599
+ ),
600
+ ];
601
+ }
602
+
603
+ const diagnostics: AdapterTargetCatalogDiagnostic[] = [];
604
+ for (const [field, identifier] of Object.entries(conformance) as [
605
+ keyof AdapterTargetConformanceManifest,
606
+ string,
607
+ ][]) {
608
+ const exportKind = adapterSourceExportKind(testingExportTarget, identifier);
609
+ if (field === 'adapterType' && adapterSourceExportKindHasType(exportKind)) {
610
+ continue;
611
+ }
612
+ if (
613
+ field !== 'adapterType' &&
614
+ adapterSourceExportKindHasValue(exportKind)
615
+ ) {
616
+ continue;
617
+ }
618
+ const expectedExport =
619
+ field === 'adapterType' ? 'type export' : 'value export';
620
+ diagnostics.push(
621
+ diagnostic(
622
+ context,
623
+ 'invalid-conformance',
624
+ `Adapter target "${target}" declares conformance.${field} "${identifier}", but ${context.packageName} does not provide it as a ${expectedExport} from testingImport.`,
625
+ target
626
+ )
627
+ );
628
+ }
629
+
630
+ return diagnostics;
631
+ };
632
+
633
+ const missingExportDiagnostic = (
634
+ context: AdapterTargetParseContext,
635
+ field: 'supportImport' | 'testingImport',
636
+ importSpecifier: string,
637
+ target: string
638
+ ): AdapterTargetCatalogDiagnostic =>
639
+ diagnostic(
640
+ context,
641
+ 'invalid-import',
642
+ `Adapter target "${target}" declares ${field} "${importSpecifier}", but ${context.packageName} does not export that subpath.`,
643
+ target
644
+ );
645
+
646
+ const missingExportTargetDiagnostic = (
647
+ context: AdapterTargetParseContext,
648
+ field: 'supportImport' | 'testingImport',
649
+ importSpecifier: string,
650
+ target: string
651
+ ): AdapterTargetCatalogDiagnostic =>
652
+ diagnostic(
653
+ context,
654
+ 'invalid-import',
655
+ `Adapter target "${target}" declares ${field} "${importSpecifier}", but ${context.packageName} exports that subpath to a missing or non-file target.`,
656
+ target
657
+ );
658
+
659
+ const adapterTargetsRecord = (
660
+ manifest: AdapterTargetPackageManifest
661
+ ): Record<string, unknown> | undefined | null => {
662
+ const trails = isRecord(manifest.trails) ? manifest.trails : undefined;
663
+ const adapterTargets = trails?.['adapterTargets'];
664
+ if (adapterTargets === undefined) {
665
+ return undefined;
666
+ }
667
+ return isRecord(adapterTargets) ? adapterTargets : null;
668
+ };
669
+
670
+ const parseCatalogTarget = (
671
+ context: AdapterTargetParseContext,
672
+ target: string,
673
+ entry: unknown
674
+ ): ParsedCatalogTarget => {
675
+ if (!targetIdPattern.test(target) || !isRecord(entry)) {
676
+ return {
677
+ diagnostics: [
678
+ diagnostic(
679
+ context,
680
+ 'invalid-adapter-target',
681
+ 'Adapter target entries must use a kebab-case id and object value.',
682
+ target
683
+ ),
684
+ ],
685
+ };
686
+ }
687
+
688
+ const placements = normalizePlacements(entry['placements'], context, target);
689
+ const supportImport = normalizeOptionalImport(
690
+ entry['supportImport'],
691
+ 'supportImport',
692
+ context,
693
+ target
694
+ );
695
+ const testingImport = normalizeOptionalImport(
696
+ entry['testingImport'],
697
+ 'testingImport',
698
+ context,
699
+ target
700
+ );
701
+ const conformance = normalizeConformance(
702
+ entry['conformance'],
703
+ context,
704
+ target,
705
+ testingImport.importSpecifier !== undefined
706
+ );
707
+ const importDiagnostics = [
708
+ ...placements.diagnostics,
709
+ ...supportImport.diagnostics,
710
+ ...testingImport.diagnostics,
711
+ ...conformance.diagnostics,
712
+ ];
713
+ const supportImportSpecifier = supportImport.importSpecifier;
714
+ const supportExportTarget = supportImportSpecifier
715
+ ? resolveExportTargetForImport(context, supportImportSpecifier)
716
+ : undefined;
717
+ const testingImportSpecifier = testingImport.importSpecifier;
718
+ const conformanceManifest = conformance.conformance;
719
+ const testingExportTarget = testingImportSpecifier
720
+ ? resolveExportTargetForImport(context, testingImportSpecifier)
721
+ : undefined;
722
+
723
+ if (supportImportSpecifier) {
724
+ if (!supportExportTarget) {
725
+ importDiagnostics.push(
726
+ missingExportDiagnostic(
727
+ context,
728
+ 'supportImport',
729
+ supportImportSpecifier,
730
+ target
731
+ )
732
+ );
733
+ } else if (!pathIsFile(supportExportTarget)) {
734
+ importDiagnostics.push(
735
+ missingExportTargetDiagnostic(
736
+ context,
737
+ 'supportImport',
738
+ supportImportSpecifier,
739
+ target
740
+ )
741
+ );
742
+ }
743
+ }
744
+ if (testingImportSpecifier) {
745
+ if (!testingExportTarget) {
746
+ importDiagnostics.push(
747
+ missingExportDiagnostic(
748
+ context,
749
+ 'testingImport',
750
+ testingImportSpecifier,
751
+ target
752
+ )
753
+ );
754
+ } else if (!pathIsFile(testingExportTarget)) {
755
+ importDiagnostics.push(
756
+ missingExportTargetDiagnostic(
757
+ context,
758
+ 'testingImport',
759
+ testingImportSpecifier,
760
+ target
761
+ )
762
+ );
763
+ }
764
+ }
765
+ if (conformanceManifest && testingExportTarget) {
766
+ importDiagnostics.push(
767
+ ...conformanceExportDiagnostics(
768
+ context,
769
+ target,
770
+ conformanceManifest,
771
+ testingExportTarget
772
+ )
773
+ );
774
+ }
775
+ if (importDiagnostics.length > 0 || placements.placements.length === 0) {
776
+ return { diagnostics: importDiagnostics };
777
+ }
778
+
779
+ return {
780
+ diagnostics: [],
781
+ targetEntry: {
782
+ key: `${context.packageName}:${target}`,
783
+ ownerPackage: context.packageName,
784
+ packageJsonPath: context.packageJsonPath,
785
+ packageRoot: context.packageRoot,
786
+ placements: placements.placements,
787
+ ...(supportImportSpecifier
788
+ ? {
789
+ ...(supportExportTarget ? { supportExportTarget } : {}),
790
+ supportImport: supportImportSpecifier,
791
+ }
792
+ : {}),
793
+ target,
794
+ ...(testingImportSpecifier
795
+ ? {
796
+ ...(testingExportTarget ? { testingExportTarget } : {}),
797
+ ...(conformanceManifest
798
+ ? { conformance: conformanceManifest }
799
+ : {}),
800
+ testingImport: testingImportSpecifier,
801
+ }
802
+ : {}),
803
+ },
804
+ };
805
+ };
806
+
807
+ export const parseAdapterTargetsFromManifest = (
808
+ manifest: AdapterTargetPackageManifest,
809
+ context: AdapterTargetParseContext
810
+ ): AdapterTargetCatalog => {
811
+ const adapterTargets = adapterTargetsRecord(manifest);
812
+ if (adapterTargets === undefined) {
813
+ return { diagnostics: [], targets: [] };
814
+ }
815
+ if (adapterTargets === null) {
816
+ return {
817
+ diagnostics: [
818
+ diagnostic(
819
+ context,
820
+ 'invalid-adapter-targets',
821
+ '`trails.adapterTargets` must be an object keyed by adapter target id.'
822
+ ),
823
+ ],
824
+ targets: [],
825
+ };
826
+ }
827
+
828
+ const diagnostics: AdapterTargetCatalogDiagnostic[] = [];
829
+ const targets: AdapterTargetCatalogEntry[] = [];
830
+
831
+ for (const [target, entry] of Object.entries(adapterTargets).toSorted()) {
832
+ const parsedTarget = parseCatalogTarget(context, target, entry);
833
+ diagnostics.push(...parsedTarget.diagnostics);
834
+ if (parsedTarget.targetEntry) {
835
+ targets.push(parsedTarget.targetEntry);
836
+ }
837
+ }
838
+
839
+ return {
840
+ diagnostics,
841
+ targets,
842
+ };
843
+ };
844
+
845
+ export const deriveAdapterTargetCatalog = (
846
+ rootDir: string
847
+ ): AdapterTargetCatalog => {
848
+ const diagnostics: AdapterTargetCatalogDiagnostic[] = [];
849
+ const targets: AdapterTargetCatalogEntry[] = [];
850
+
851
+ for (const workspacePackage of listWorkspacePackages<NamedAdapterTargetPackageManifest>(
852
+ rootDir
853
+ )) {
854
+ const { manifest, packageJsonPath, packageRoot } = workspacePackage;
855
+ const normalizedExports = normalizeExportTargets(
856
+ packageRoot,
857
+ manifest.name,
858
+ manifest.exports
859
+ );
860
+ const parsed = parseAdapterTargetsFromManifest(manifest, {
861
+ blockedExportSpecifiers: normalizedExports.blocked,
862
+ exportTargets: normalizedExports.targets,
863
+ packageJsonPath,
864
+ packageName: manifest.name,
865
+ packageRoot,
866
+ });
867
+ diagnostics.push(...parsed.diagnostics);
868
+ targets.push(...parsed.targets);
869
+ }
870
+
871
+ const sortedTargets = targets.toSorted((left, right) =>
872
+ left.key.localeCompare(right.key)
873
+ );
874
+ const uniqueTargets = rejectDuplicateTargetIds(sortedTargets);
875
+
876
+ return {
877
+ diagnostics: [...diagnostics, ...uniqueTargets.diagnostics],
878
+ targets: uniqueTargets.targets,
879
+ };
880
+ };