@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/check.ts ADDED
@@ -0,0 +1,2495 @@
1
+ /**
2
+ * Shared adapter readiness checks for Warden and local author tooling.
3
+ *
4
+ * The engine reads package manifests and source files. It does not import
5
+ * runtime adapter packages, and runtime adapters must not import it.
6
+ */
7
+
8
+ import {
9
+ existsSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ statSync,
14
+ } from 'node:fs';
15
+ import { dirname, join, relative, resolve } from 'node:path';
16
+
17
+ import { escapeRegExp, listWorkspacePackages } from '@ontrails/core';
18
+ import type {
19
+ DiagnosticBase,
20
+ WorkspacePackage as CoreWorkspacePackage,
21
+ } from '@ontrails/core';
22
+ import {
23
+ identifierName,
24
+ isShadowed,
25
+ parse,
26
+ walkWithScopes,
27
+ } from '@ontrails/source';
28
+ import type { AstNode } from '@ontrails/source';
29
+
30
+ import { deriveAdapterTargetCatalog } from './catalog.js';
31
+ import type {
32
+ AdapterTargetCatalog,
33
+ AdapterTargetCatalogDiagnosticCode,
34
+ AdapterTargetCatalogEntry,
35
+ AdapterTargetPlacement,
36
+ } from './catalog.js';
37
+
38
+ export type AdapterCheckDiagnosticCode =
39
+ | AdapterTargetCatalogDiagnosticCode
40
+ | 'dependency-direction'
41
+ | 'invalid-adapter-metadata'
42
+ | 'missing-conformance'
43
+ | 'missing-owner-conformance'
44
+ | 'missing-package-export'
45
+ | 'tooling-boundary'
46
+ | 'unknown-adapter-target'
47
+ | 'unsupported-placement';
48
+
49
+ export type AdapterCheckDiagnosticSeverity = DiagnosticBase['severity'];
50
+
51
+ export interface AdapterCheckDiagnostic extends DiagnosticBase<AdapterCheckDiagnosticCode> {
52
+ readonly code: AdapterCheckDiagnosticCode;
53
+ readonly message: string;
54
+ readonly packageJsonPath: string;
55
+ readonly packageName?: string | undefined;
56
+ readonly placement?: AdapterTargetPlacement | undefined;
57
+ readonly target?: string | undefined;
58
+ }
59
+
60
+ export interface AdapterCheckSubject {
61
+ readonly adapterType?: string | undefined;
62
+ readonly conformanceTestPaths: readonly string[];
63
+ readonly key: string;
64
+ readonly ownerPackage: string;
65
+ readonly packageJsonPath: string;
66
+ readonly packageName: string;
67
+ readonly packageRoot: string;
68
+ readonly placement: AdapterTargetPlacement;
69
+ readonly target: string;
70
+ readonly targetKey: string;
71
+ readonly testingImport?: string | undefined;
72
+ }
73
+
74
+ export type AdapterFactKind = 'available' | 'configured' | 'observed' | 'used';
75
+
76
+ export type AdapterFactProvenanceSource =
77
+ | 'adapter-package-manifest'
78
+ | 'conformance-test'
79
+ | 'owner-package-manifest'
80
+ | 'runtime-observation';
81
+
82
+ export interface AdapterFactProvenance {
83
+ readonly packageJsonPath?: string | undefined;
84
+ readonly paths?: readonly string[] | undefined;
85
+ readonly source: AdapterFactProvenanceSource;
86
+ }
87
+
88
+ export interface AdapterFact {
89
+ readonly adapterType?: string | undefined;
90
+ readonly key: string;
91
+ readonly kind: AdapterFactKind;
92
+ readonly ownerPackage?: string | undefined;
93
+ readonly packageName?: string | undefined;
94
+ readonly placement?: AdapterTargetPlacement | undefined;
95
+ readonly placements?: readonly AdapterTargetPlacement[] | undefined;
96
+ readonly provenance: AdapterFactProvenance;
97
+ readonly target: string;
98
+ readonly targetKey?: string | undefined;
99
+ }
100
+
101
+ export interface AdapterCheckReport {
102
+ readonly diagnostics: readonly AdapterCheckDiagnostic[];
103
+ readonly facts: readonly AdapterFact[];
104
+ readonly subjects: readonly AdapterCheckSubject[];
105
+ readonly targets: readonly AdapterTargetCatalogEntry[];
106
+ }
107
+
108
+ interface AdapterCheckPackageManifest {
109
+ readonly dependencies?: unknown;
110
+ readonly devDependencies?: unknown;
111
+ readonly exports?: unknown;
112
+ readonly name?: unknown;
113
+ readonly optionalDependencies?: unknown;
114
+ readonly peerDependencies?: unknown;
115
+ readonly trails?: unknown;
116
+ }
117
+
118
+ type WorkspacePackage = CoreWorkspacePackage<AdapterCheckPackageManifest>;
119
+
120
+ interface AdapterMetadata {
121
+ readonly target: string;
122
+ }
123
+
124
+ interface SubpathAdapterMetadata extends AdapterMetadata {
125
+ readonly exportKey: string;
126
+ }
127
+
128
+ interface AdapterCheckCandidate {
129
+ readonly exportKey?: string | undefined;
130
+ readonly key: string;
131
+ readonly metadata: AdapterMetadata;
132
+ readonly packageName: string;
133
+ readonly placement: AdapterTargetPlacement;
134
+ }
135
+
136
+ const adapterKitPackageName = '@ontrails/adapter-kit';
137
+
138
+ const targetIdPattern = /^[a-z][a-z0-9-]*$/u;
139
+ const subpathAdapterExportKeyPattern =
140
+ /^\.\/[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-]*)*$/u;
141
+
142
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
143
+ Boolean(value && typeof value === 'object' && !Array.isArray(value));
144
+
145
+ const normalizePath = (path: string): string => path.replaceAll('\\', '/');
146
+
147
+ const normalizeRealPath = (path: string): string => {
148
+ try {
149
+ return normalizePath(realpathSync(path));
150
+ } catch {
151
+ return normalizePath(resolve(path));
152
+ }
153
+ };
154
+
155
+ const workspacePackages = (rootDir: string): readonly WorkspacePackage[] =>
156
+ listWorkspacePackages<AdapterCheckPackageManifest>(rootDir);
157
+
158
+ const resolveExportTarget = (
159
+ target: unknown,
160
+ depth = 0
161
+ ): string | undefined => {
162
+ if (typeof target === 'string') {
163
+ return target;
164
+ }
165
+ if (!isRecord(target) || depth > 8) {
166
+ return undefined;
167
+ }
168
+
169
+ for (const condition of ['bun', 'import', 'default', 'require'] as const) {
170
+ const resolvedTarget = resolveExportTarget(target[condition], depth + 1);
171
+ if (resolvedTarget) {
172
+ return resolvedTarget;
173
+ }
174
+ }
175
+ return undefined;
176
+ };
177
+
178
+ const isPackageRelativePath = (path: string): boolean =>
179
+ path.startsWith('./') && !normalizePath(path).includes('/../');
180
+
181
+ const exportTargetIsFile = (packageRoot: string, target: string): boolean => {
182
+ if (!isPackageRelativePath(target)) {
183
+ return false;
184
+ }
185
+
186
+ try {
187
+ return statSync(resolve(packageRoot, target)).isFile();
188
+ } catch {
189
+ return false;
190
+ }
191
+ };
192
+
193
+ const resolvableExportTarget = (
194
+ workspace: WorkspacePackage,
195
+ key: string
196
+ ): string | undefined => {
197
+ const { exports: exportsValue } = workspace.manifest;
198
+ if (typeof exportsValue === 'string') {
199
+ return key === '.' &&
200
+ exportTargetIsFile(workspace.packageRoot, exportsValue)
201
+ ? normalizeRealPath(resolve(workspace.packageRoot, exportsValue))
202
+ : undefined;
203
+ }
204
+
205
+ if (!isRecord(exportsValue)) {
206
+ return undefined;
207
+ }
208
+
209
+ if (!Object.hasOwn(exportsValue, key)) {
210
+ if (key !== '.') {
211
+ return undefined;
212
+ }
213
+
214
+ const rootTarget = resolveExportTarget(exportsValue);
215
+ return rootTarget !== undefined &&
216
+ exportTargetIsFile(workspace.packageRoot, rootTarget)
217
+ ? normalizeRealPath(resolve(workspace.packageRoot, rootTarget))
218
+ : undefined;
219
+ }
220
+
221
+ const target = resolveExportTarget(exportsValue[key]);
222
+ return target !== undefined &&
223
+ exportTargetIsFile(workspace.packageRoot, target)
224
+ ? normalizeRealPath(resolve(workspace.packageRoot, target))
225
+ : undefined;
226
+ };
227
+
228
+ const hasResolvableExport = (
229
+ workspace: WorkspacePackage,
230
+ key: string
231
+ ): boolean => resolvableExportTarget(workspace, key) !== undefined;
232
+
233
+ const dependencyMap = (value: unknown): Readonly<Record<string, unknown>> =>
234
+ isRecord(value) ? value : {};
235
+
236
+ const runtimeDependencyNames = (
237
+ manifest: AdapterCheckPackageManifest
238
+ ): ReadonlySet<string> =>
239
+ new Set([
240
+ ...Object.keys(dependencyMap(manifest.dependencies)),
241
+ ...Object.keys(dependencyMap(manifest.optionalDependencies)),
242
+ ...Object.keys(dependencyMap(manifest.peerDependencies)),
243
+ ]);
244
+
245
+ const trailAdapterMetadata = (
246
+ manifest: AdapterCheckPackageManifest
247
+ ): AdapterMetadata | undefined | null => {
248
+ const trails = isRecord(manifest.trails) ? manifest.trails : undefined;
249
+ const adapter = trails?.['adapter'];
250
+ if (adapter === undefined) {
251
+ return undefined;
252
+ }
253
+ if (!isRecord(adapter)) {
254
+ return null;
255
+ }
256
+
257
+ const { target } = adapter;
258
+ return typeof target === 'string' && targetIdPattern.test(target)
259
+ ? { target }
260
+ : null;
261
+ };
262
+
263
+ const trailSubpathAdapterMetadata = (
264
+ manifest: AdapterCheckPackageManifest
265
+ ): readonly SubpathAdapterMetadata[] | undefined | null => {
266
+ const trails = isRecord(manifest.trails) ? manifest.trails : undefined;
267
+ const adapters = trails?.['adapters'];
268
+ if (adapters === undefined) {
269
+ return undefined;
270
+ }
271
+ if (!isRecord(adapters)) {
272
+ return null;
273
+ }
274
+
275
+ const metadata: SubpathAdapterMetadata[] = [];
276
+ for (const [exportKey, adapter] of Object.entries(adapters).toSorted()) {
277
+ if (!subpathAdapterExportKeyPattern.test(exportKey)) {
278
+ return null;
279
+ }
280
+ if (!isRecord(adapter)) {
281
+ return null;
282
+ }
283
+
284
+ const { target } = adapter;
285
+ if (typeof target !== 'string' || !targetIdPattern.test(target)) {
286
+ return null;
287
+ }
288
+ metadata.push({ exportKey, target });
289
+ }
290
+
291
+ return metadata;
292
+ };
293
+
294
+ const placementForWorkspace = (
295
+ workspacePath: string
296
+ ): AdapterTargetPlacement | undefined =>
297
+ workspacePath.startsWith('adapters/') ? 'extracted' : undefined;
298
+
299
+ const diagnostic = (
300
+ packageJsonPath: string,
301
+ packageName: string | undefined,
302
+ code: AdapterCheckDiagnosticCode,
303
+ message: string,
304
+ target?: string,
305
+ placement?: AdapterTargetPlacement
306
+ ): AdapterCheckDiagnostic => ({
307
+ code,
308
+ message,
309
+ packageJsonPath,
310
+ ...(packageName === undefined ? {} : { packageName }),
311
+ ...(placement === undefined ? {} : { placement }),
312
+ severity: 'error',
313
+ ...(target === undefined ? {} : { target }),
314
+ });
315
+
316
+ const catalogDiagnostics = (
317
+ catalog: AdapterTargetCatalog
318
+ ): readonly AdapterCheckDiagnostic[] =>
319
+ catalog.diagnostics.map((entry) =>
320
+ diagnostic(
321
+ entry.packageJsonPath,
322
+ entry.packageName,
323
+ entry.code,
324
+ entry.message,
325
+ entry.target
326
+ )
327
+ );
328
+
329
+ const targetEntriesByTarget = (
330
+ targets: readonly AdapterTargetCatalogEntry[]
331
+ ): ReadonlyMap<string, AdapterTargetCatalogEntry> =>
332
+ new Map(targets.map((target) => [target.target, target]));
333
+
334
+ const adapterFacts = (
335
+ targets: readonly AdapterTargetCatalogEntry[],
336
+ subjects: readonly AdapterCheckSubject[]
337
+ ): readonly AdapterFact[] => {
338
+ const facts: AdapterFact[] = [];
339
+
340
+ for (const target of targets) {
341
+ facts.push({
342
+ key: `${target.key}:available`,
343
+ kind: 'available',
344
+ ownerPackage: target.ownerPackage,
345
+ placements: target.placements,
346
+ provenance: {
347
+ packageJsonPath: target.packageJsonPath,
348
+ source: 'owner-package-manifest',
349
+ },
350
+ target: target.target,
351
+ targetKey: target.key,
352
+ });
353
+ }
354
+
355
+ for (const subject of subjects) {
356
+ facts.push({
357
+ key: `${subject.key}:${subject.target}:configured`,
358
+ kind: 'configured',
359
+ ownerPackage: subject.ownerPackage,
360
+ packageName: subject.packageName,
361
+ placement: subject.placement,
362
+ provenance: {
363
+ packageJsonPath: subject.packageJsonPath,
364
+ source:
365
+ subject.placement === 'subpath'
366
+ ? 'owner-package-manifest'
367
+ : 'adapter-package-manifest',
368
+ },
369
+ target: subject.target,
370
+ targetKey: subject.targetKey,
371
+ ...(subject.adapterType ? { adapterType: subject.adapterType } : {}),
372
+ });
373
+
374
+ if (subject.conformanceTestPaths.length > 0) {
375
+ facts.push({
376
+ key: `${subject.key}:${subject.target}:used`,
377
+ kind: 'used',
378
+ ownerPackage: subject.ownerPackage,
379
+ packageName: subject.packageName,
380
+ placement: subject.placement,
381
+ provenance: {
382
+ paths: subject.conformanceTestPaths,
383
+ source: 'conformance-test',
384
+ },
385
+ target: subject.target,
386
+ targetKey: subject.targetKey,
387
+ ...(subject.adapterType ? { adapterType: subject.adapterType } : {}),
388
+ });
389
+ }
390
+ }
391
+
392
+ return facts;
393
+ };
394
+
395
+ const collectSourceFiles = (dir: string): readonly string[] => {
396
+ if (!existsSync(dir)) {
397
+ return [];
398
+ }
399
+
400
+ const files: string[] = [];
401
+ const visit = (current: string): void => {
402
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
403
+ const child = join(current, entry.name);
404
+ if (entry.isDirectory()) {
405
+ visit(child);
406
+ continue;
407
+ }
408
+ if (entry.isFile() && child.endsWith('.ts')) {
409
+ files.push(normalizeRealPath(child));
410
+ }
411
+ }
412
+ };
413
+
414
+ visit(dir);
415
+ return files.toSorted();
416
+ };
417
+
418
+ const isIdentifierChar = (char: string | undefined): boolean =>
419
+ char !== undefined && /[$\w]/u.test(char);
420
+
421
+ const skipWhitespace = (source: string, start: number): number => {
422
+ let index = start;
423
+ while (/\s/u.test(source[index] ?? '')) {
424
+ index += 1;
425
+ }
426
+ return index;
427
+ };
428
+
429
+ const previousNonWhitespaceChar = (
430
+ source: string,
431
+ start: number
432
+ ): string | undefined => {
433
+ let index = start - 1;
434
+ while (index >= 0) {
435
+ const char = source[index];
436
+ if (!/\s/u.test(char ?? '')) {
437
+ return char;
438
+ }
439
+ index -= 1;
440
+ }
441
+ return undefined;
442
+ };
443
+
444
+ const regexLiteralCanStartAfter = (char: string | undefined): boolean =>
445
+ char === undefined || /[([{:;,=!?&|+\-*%^~<>]/u.test(char);
446
+
447
+ const regexLiteralCanStartAfterKeyword = (
448
+ source: string,
449
+ start: number
450
+ ): boolean =>
451
+ /\b(?:await|case|delete|do|else|in|instanceof|of|return|throw|typeof|void|yield)\s*$/u.test(
452
+ source.slice(0, start).trimEnd()
453
+ );
454
+
455
+ const startsRegexLiteral = (source: string, start: number): boolean => {
456
+ if (
457
+ source[start] !== '/' ||
458
+ source.startsWith('//', start) ||
459
+ source.startsWith('/*', start)
460
+ ) {
461
+ return false;
462
+ }
463
+
464
+ const previousChar = previousNonWhitespaceChar(source, start);
465
+ return (
466
+ regexLiteralCanStartAfter(previousChar) ||
467
+ regexLiteralCanStartAfterKeyword(source, start)
468
+ );
469
+ };
470
+
471
+ const skipRegexLiteral = (source: string, start: number): number => {
472
+ let index = start + 1;
473
+ let inCharacterClass = false;
474
+ while (index < source.length) {
475
+ const char = source[index];
476
+ if (char === '\\') {
477
+ index += 2;
478
+ continue;
479
+ }
480
+ if (char === '[') {
481
+ inCharacterClass = true;
482
+ index += 1;
483
+ continue;
484
+ }
485
+ if (char === ']' && inCharacterClass) {
486
+ inCharacterClass = false;
487
+ index += 1;
488
+ continue;
489
+ }
490
+ if (char === '/' && !inCharacterClass) {
491
+ index += 1;
492
+ while (/[a-z]/iu.test(source[index] ?? '')) {
493
+ index += 1;
494
+ }
495
+ return index;
496
+ }
497
+ index += 1;
498
+ }
499
+ return source.length;
500
+ };
501
+
502
+ const readQuotedString = (
503
+ source: string,
504
+ start: number
505
+ ): { readonly end: number; readonly value: string } | undefined => {
506
+ const quote = source[start];
507
+ if (quote !== '"' && quote !== "'") {
508
+ return undefined;
509
+ }
510
+
511
+ let index = start + 1;
512
+ let value = '';
513
+ while (index < source.length) {
514
+ const char = source[index];
515
+ if (char === '\\') {
516
+ value += source[index + 1] ?? '';
517
+ index += 2;
518
+ continue;
519
+ }
520
+ if (char === quote) {
521
+ return { end: index + 1, value };
522
+ }
523
+ value += char;
524
+ index += 1;
525
+ }
526
+
527
+ return undefined;
528
+ };
529
+
530
+ const skipBlockComment = (source: string, start: number): number => {
531
+ const end = source.indexOf('*/', start + 2);
532
+ return end === -1 ? source.length : end + 2;
533
+ };
534
+
535
+ const skipLineComment = (source: string, start: number): number => {
536
+ const end = source.indexOf('\n', start + 2);
537
+ return end === -1 ? source.length : end + 1;
538
+ };
539
+
540
+ const skipTrivia = (source: string, start: number): number => {
541
+ let index = skipWhitespace(source, start);
542
+ while (index < source.length) {
543
+ if (source.startsWith('//', index)) {
544
+ index = skipWhitespace(source, skipLineComment(source, index));
545
+ continue;
546
+ }
547
+ if (source.startsWith('/*', index)) {
548
+ index = skipWhitespace(source, skipBlockComment(source, index));
549
+ continue;
550
+ }
551
+ return index;
552
+ }
553
+ return index;
554
+ };
555
+
556
+ const skipQuotedLiteral = (source: string, start: number): number => {
557
+ const quote = source[start];
558
+ let index = start + 1;
559
+ while (index < source.length) {
560
+ if (source[index] === '\\') {
561
+ index += 2;
562
+ continue;
563
+ }
564
+ if (source[index] === quote) {
565
+ return index + 1;
566
+ }
567
+ index += 1;
568
+ }
569
+ return source.length;
570
+ };
571
+
572
+ const skipImportScanIgnoredToken = (
573
+ source: string,
574
+ start: number
575
+ ): number | undefined => {
576
+ if (source.startsWith('//', start)) {
577
+ return skipLineComment(source, start);
578
+ }
579
+ if (source.startsWith('/*', start)) {
580
+ return skipBlockComment(source, start);
581
+ }
582
+
583
+ const char = source[start];
584
+ if (char === '"' || char === "'" || char === '`') {
585
+ return skipQuotedLiteral(source, start);
586
+ }
587
+ if (char === '/' && startsRegexLiteral(source, start)) {
588
+ return skipRegexLiteral(source, start);
589
+ }
590
+
591
+ return undefined;
592
+ };
593
+
594
+ const importClauseHasRuntimeBinding = (
595
+ clause: string,
596
+ options: { readonly emptyNamedCounts?: boolean } = {}
597
+ ): boolean => {
598
+ const emptyNamedCounts = options.emptyNamedCounts ?? true;
599
+ const uncommented = clause
600
+ .replaceAll(/\/\*[\s\S]*?\*\//g, ' ')
601
+ .replaceAll(/\/\/[^\n\r]*/g, ' ');
602
+ const trimmed = uncommented.trim();
603
+ if (!trimmed) {
604
+ return false;
605
+ }
606
+
607
+ const namedStart = trimmed.indexOf('{');
608
+ if (namedStart === -1) {
609
+ return true;
610
+ }
611
+
612
+ if (trimmed.slice(0, namedStart).replaceAll(',', '').trim()) {
613
+ return true;
614
+ }
615
+
616
+ const namedEnd = trimmed.indexOf('}', namedStart + 1);
617
+ if (namedEnd === -1) {
618
+ return emptyNamedCounts;
619
+ }
620
+
621
+ const namedBindings = trimmed
622
+ .slice(namedStart + 1, namedEnd)
623
+ .split(',')
624
+ .map((binding) => binding.trim())
625
+ .filter(Boolean);
626
+ if (namedBindings.length === 0) {
627
+ return emptyNamedCounts;
628
+ }
629
+
630
+ return namedBindings.some((binding) => !/^type(?:\s|$)/u.test(binding));
631
+ };
632
+
633
+ const previousStatementStart = (source: string, start: number): number => {
634
+ let index = start - 1;
635
+ while (index >= 0) {
636
+ if (source[index] === ';' || source[index] === '}') {
637
+ return index + 1;
638
+ }
639
+ index -= 1;
640
+ }
641
+ return 0;
642
+ };
643
+
644
+ const importAppearsInTypePosition = (
645
+ source: string,
646
+ start: number
647
+ ): boolean => {
648
+ const prefix = source
649
+ .slice(previousStatementStart(source, start), start)
650
+ .replaceAll(/\/\*[\s\S]*?\*\//g, ' ')
651
+ .replaceAll(/\/\/[^\n\r]*/g, ' ')
652
+ .trimStart();
653
+ const lastLineStart =
654
+ Math.max(prefix.lastIndexOf('\n'), prefix.lastIndexOf('\r')) + 1;
655
+ const lastLine = prefix.slice(lastLineStart).trimStart();
656
+ if (
657
+ /^(?:export\s+)?(?:type|interface)\b/u.test(prefix) &&
658
+ lastLineStart > 0 &&
659
+ /^(?:const|let|var|using|await|return|throw|void|yield|new)\b/u.test(
660
+ lastLine
661
+ )
662
+ ) {
663
+ return false;
664
+ }
665
+
666
+ if (/^(?:export\s+)?(?:type|interface)\b/u.test(prefix)) {
667
+ return true;
668
+ }
669
+
670
+ const trimmed = prefix.trimEnd();
671
+ const lastAssignment = prefix.lastIndexOf('=');
672
+ const colonLooksLikeTypeAnnotation = (colonIndex: number): boolean => {
673
+ const objectLiteralStart = prefix.indexOf('{', lastAssignment + 1);
674
+ if (objectLiteralStart !== -1 && objectLiteralStart < colonIndex) {
675
+ return false;
676
+ }
677
+
678
+ const blockStart = prefix.lastIndexOf('{');
679
+ if (blockStart > colonIndex) {
680
+ const blockTail = prefix.slice(blockStart + 1).trimStart();
681
+ if (
682
+ /^(?:const|let|var|using|await|return|throw|void|yield|new)\b/u.test(
683
+ blockTail
684
+ )
685
+ ) {
686
+ return false;
687
+ }
688
+ }
689
+
690
+ const questionIndex = prefix.indexOf('?', lastAssignment + 1);
691
+ return questionIndex === -1 || questionIndex > colonIndex;
692
+ };
693
+
694
+ if (trimmed.endsWith('<') && isIdentifierChar(trimmed.at(-2))) {
695
+ return true;
696
+ }
697
+
698
+ if (trimmed.endsWith(':')) {
699
+ const trailingColon = prefix.lastIndexOf(':');
700
+ return colonLooksLikeTypeAnnotation(trailingColon);
701
+ }
702
+
703
+ if (/\b(?:as|extends|implements|satisfies|typeof)\s*$/u.test(trimmed)) {
704
+ return true;
705
+ }
706
+
707
+ const annotationColon = prefix.indexOf(':', lastAssignment + 1);
708
+ if (annotationColon === -1) {
709
+ return false;
710
+ }
711
+
712
+ return colonLooksLikeTypeAnnotation(annotationColon);
713
+ };
714
+
715
+ const importDeclarationSpecifier = (
716
+ source: string,
717
+ start: number
718
+ ): string | undefined => {
719
+ let index = skipTrivia(source, start + 'import'.length);
720
+ if (source[index] === '.') {
721
+ return undefined;
722
+ }
723
+ if (
724
+ source.startsWith('type', index) &&
725
+ !isIdentifierChar(source[index + 'type'.length])
726
+ ) {
727
+ return undefined;
728
+ }
729
+ const sideEffect = readQuotedString(source, index);
730
+ if (sideEffect) {
731
+ return sideEffect.value;
732
+ }
733
+
734
+ if (source[index] === '(') {
735
+ if (importAppearsInTypePosition(source, start)) {
736
+ return undefined;
737
+ }
738
+ index = skipTrivia(source, index + 1);
739
+ return readQuotedString(source, index)?.value;
740
+ }
741
+
742
+ const clauseStart = index;
743
+ while (index < source.length) {
744
+ if (source.startsWith('//', index)) {
745
+ index = skipLineComment(source, index);
746
+ continue;
747
+ }
748
+ if (source.startsWith('/*', index)) {
749
+ index = skipBlockComment(source, index);
750
+ continue;
751
+ }
752
+ const char = source[index];
753
+ if (char === '"' || char === "'" || char === '`') {
754
+ index = skipQuotedLiteral(source, index);
755
+ continue;
756
+ }
757
+ if (
758
+ source.startsWith('from', index) &&
759
+ !isIdentifierChar(source[index - 1]) &&
760
+ !isIdentifierChar(source[index + 'from'.length])
761
+ ) {
762
+ if (!importClauseHasRuntimeBinding(source.slice(clauseStart, index))) {
763
+ return undefined;
764
+ }
765
+ index = skipTrivia(source, index + 'from'.length);
766
+ return readQuotedString(source, index)?.value;
767
+ }
768
+ if (char === ';') {
769
+ return undefined;
770
+ }
771
+ index += 1;
772
+ }
773
+
774
+ return undefined;
775
+ };
776
+
777
+ const boundImportDeclarationSpecifier = (
778
+ source: string,
779
+ start: number
780
+ ): string | undefined => {
781
+ let index = skipTrivia(source, start + 'import'.length);
782
+ if (source[index] === '.') {
783
+ return undefined;
784
+ }
785
+ if (
786
+ source.startsWith('type', index) &&
787
+ !isIdentifierChar(source[index + 'type'.length])
788
+ ) {
789
+ return undefined;
790
+ }
791
+ if (readQuotedString(source, index)) {
792
+ return undefined;
793
+ }
794
+
795
+ if (source[index] === '(') {
796
+ if (importAppearsInTypePosition(source, start)) {
797
+ return undefined;
798
+ }
799
+ index = skipTrivia(source, index + 1);
800
+ return readQuotedString(source, index)?.value;
801
+ }
802
+
803
+ const clauseStart = index;
804
+ while (index < source.length) {
805
+ if (source.startsWith('//', index)) {
806
+ index = skipLineComment(source, index);
807
+ continue;
808
+ }
809
+ if (source.startsWith('/*', index)) {
810
+ index = skipBlockComment(source, index);
811
+ continue;
812
+ }
813
+ const char = source[index];
814
+ if (char === '"' || char === "'" || char === '`') {
815
+ index = skipQuotedLiteral(source, index);
816
+ continue;
817
+ }
818
+ if (
819
+ source.startsWith('from', index) &&
820
+ !isIdentifierChar(source[index - 1]) &&
821
+ !isIdentifierChar(source[index + 'from'.length])
822
+ ) {
823
+ if (
824
+ !importClauseHasRuntimeBinding(source.slice(clauseStart, index), {
825
+ emptyNamedCounts: false,
826
+ })
827
+ ) {
828
+ return undefined;
829
+ }
830
+ index = skipTrivia(source, index + 'from'.length);
831
+ return readQuotedString(source, index)?.value;
832
+ }
833
+ if (char === ';') {
834
+ return undefined;
835
+ }
836
+ index += 1;
837
+ }
838
+
839
+ return undefined;
840
+ };
841
+
842
+ const reExportDeclarationSpecifier = (
843
+ source: string,
844
+ start: number
845
+ ): string | undefined => {
846
+ let index = skipTrivia(source, start + 'export'.length);
847
+ if (
848
+ source.startsWith('type', index) &&
849
+ !isIdentifierChar(source[index + 'type'.length])
850
+ ) {
851
+ return undefined;
852
+ }
853
+
854
+ const clauseStart = index;
855
+ while (index < source.length) {
856
+ if (source.startsWith('//', index)) {
857
+ index = skipLineComment(source, index);
858
+ continue;
859
+ }
860
+ if (source.startsWith('/*', index)) {
861
+ index = skipBlockComment(source, index);
862
+ continue;
863
+ }
864
+ const char = source[index];
865
+ if (char === '"' || char === "'" || char === '`') {
866
+ index = skipQuotedLiteral(source, index);
867
+ continue;
868
+ }
869
+ if (char === '}') {
870
+ const next = skipTrivia(source, index + 1);
871
+ if (
872
+ !source.startsWith('from', next) ||
873
+ isIdentifierChar(source[next + 'from'.length])
874
+ ) {
875
+ return undefined;
876
+ }
877
+ index = next;
878
+ continue;
879
+ }
880
+ if (
881
+ source.startsWith('from', index) &&
882
+ !isIdentifierChar(source[index - 1]) &&
883
+ !isIdentifierChar(source[index + 'from'.length])
884
+ ) {
885
+ if (!importClauseHasRuntimeBinding(source.slice(clauseStart, index))) {
886
+ return undefined;
887
+ }
888
+ index = skipTrivia(source, index + 'from'.length);
889
+ return readQuotedString(source, index)?.value;
890
+ }
891
+ if (char === ';') {
892
+ return undefined;
893
+ }
894
+ index += 1;
895
+ }
896
+
897
+ return undefined;
898
+ };
899
+
900
+ const maskSource = (source: string, options: { strings: boolean }): string => {
901
+ const output = [...source];
902
+ let index = 0;
903
+
904
+ const maskRange = (start: number, end: number): void => {
905
+ for (let cursor = start; cursor < end; cursor += 1) {
906
+ if (output[cursor] !== '\n') {
907
+ output[cursor] = ' ';
908
+ }
909
+ }
910
+ };
911
+
912
+ const skipQuoted = (quote: '"' | "'" | '`'): void => {
913
+ const start = index;
914
+ index += 1;
915
+ while (index < source.length) {
916
+ if (source[index] === '\\') {
917
+ index += 2;
918
+ continue;
919
+ }
920
+ if (source[index] === quote) {
921
+ index += 1;
922
+ break;
923
+ }
924
+ index += 1;
925
+ }
926
+ if (options.strings) {
927
+ maskRange(start, index);
928
+ }
929
+ };
930
+
931
+ while (index < source.length) {
932
+ if (source.startsWith('//', index)) {
933
+ const end = source.indexOf('\n', index + 2);
934
+ const stop = end === -1 ? source.length : end;
935
+ maskRange(index, stop);
936
+ index = stop;
937
+ continue;
938
+ }
939
+ if (source.startsWith('/*', index)) {
940
+ const end = source.indexOf('*/', index + 2);
941
+ const stop = end === -1 ? source.length : end + 2;
942
+ maskRange(index, stop);
943
+ index = stop;
944
+ continue;
945
+ }
946
+ const char = source[index];
947
+ if (char === '"' || char === "'" || char === '`') {
948
+ skipQuoted(char);
949
+ continue;
950
+ }
951
+ index += 1;
952
+ }
953
+
954
+ return output.join('');
955
+ };
956
+
957
+ const matchStartsWithAnyKeyword = (
958
+ maskedSource: string,
959
+ match: RegExpMatchArray,
960
+ keywords: readonly string[]
961
+ ): boolean => {
962
+ const index = match.index ?? 0;
963
+ return keywords.some(
964
+ (keyword) =>
965
+ maskedSource.startsWith(keyword, index) &&
966
+ !isIdentifierChar(maskedSource[index - 1]) &&
967
+ !isIdentifierChar(maskedSource[index + keyword.length])
968
+ );
969
+ };
970
+
971
+ const importsSpecifier = (
972
+ source: string,
973
+ specifier: string,
974
+ options: { includeReExports?: boolean; requireImportBinding?: boolean } = {}
975
+ ): boolean => {
976
+ const includeReExports = options.includeReExports ?? true;
977
+ const importSpecifier = options.requireImportBinding
978
+ ? boundImportDeclarationSpecifier
979
+ : importDeclarationSpecifier;
980
+ let index = 0;
981
+ while (index < source.length) {
982
+ const skippedIndex = skipImportScanIgnoredToken(source, index);
983
+ if (skippedIndex !== undefined) {
984
+ index = skippedIndex;
985
+ continue;
986
+ }
987
+
988
+ if (
989
+ source.startsWith('import', index) &&
990
+ !isIdentifierChar(source[index - 1]) &&
991
+ previousNonWhitespaceChar(source, index) !== '.' &&
992
+ !isIdentifierChar(source[index + 'import'.length])
993
+ ) {
994
+ if (importSpecifier(source, index) === specifier) {
995
+ return true;
996
+ }
997
+ index += 'import'.length;
998
+ continue;
999
+ }
1000
+ if (
1001
+ includeReExports &&
1002
+ source.startsWith('export', index) &&
1003
+ !isIdentifierChar(source[index - 1]) &&
1004
+ !isIdentifierChar(source[index + 'export'.length])
1005
+ ) {
1006
+ if (reExportDeclarationSpecifier(source, index) === specifier) {
1007
+ return true;
1008
+ }
1009
+ index += 'export'.length;
1010
+ continue;
1011
+ }
1012
+ index += 1;
1013
+ }
1014
+
1015
+ return false;
1016
+ };
1017
+
1018
+ const pathsImporting = (
1019
+ sourceFiles: readonly string[],
1020
+ specifier: string,
1021
+ options?: { includeReExports?: boolean; requireImportBinding?: boolean }
1022
+ ): readonly string[] =>
1023
+ sourceFiles.filter((filePath) =>
1024
+ importsSpecifier(readFileSync(filePath, 'utf8'), specifier, options)
1025
+ );
1026
+
1027
+ const staticImportClauseForSpecifier = (
1028
+ source: string,
1029
+ start: number,
1030
+ specifier: string
1031
+ ): string | undefined => {
1032
+ let index = skipTrivia(source, start + 'import'.length);
1033
+ if (
1034
+ source.startsWith('type', index) &&
1035
+ !isIdentifierChar(source[index + 'type'.length])
1036
+ ) {
1037
+ return undefined;
1038
+ }
1039
+ if (readQuotedString(source, index) || source[index] === '(') {
1040
+ return undefined;
1041
+ }
1042
+
1043
+ const clauseStart = index;
1044
+ while (index < source.length) {
1045
+ if (source.startsWith('//', index)) {
1046
+ index = skipLineComment(source, index);
1047
+ continue;
1048
+ }
1049
+ if (source.startsWith('/*', index)) {
1050
+ index = skipBlockComment(source, index);
1051
+ continue;
1052
+ }
1053
+ const char = source[index];
1054
+ if (char === '"' || char === "'" || char === '`') {
1055
+ index = skipQuotedLiteral(source, index);
1056
+ continue;
1057
+ }
1058
+ if (
1059
+ source.startsWith('from', index) &&
1060
+ !isIdentifierChar(source[index - 1]) &&
1061
+ !isIdentifierChar(source[index + 'from'.length])
1062
+ ) {
1063
+ const clause = source.slice(clauseStart, index);
1064
+ if (!importClauseHasRuntimeBinding(clause)) {
1065
+ return undefined;
1066
+ }
1067
+ index = skipTrivia(source, index + 'from'.length);
1068
+ return readQuotedString(source, index)?.value === specifier
1069
+ ? clause
1070
+ : undefined;
1071
+ }
1072
+ if (char === ';') {
1073
+ return undefined;
1074
+ }
1075
+ index += 1;
1076
+ }
1077
+
1078
+ return undefined;
1079
+ };
1080
+
1081
+ const staticImportClausesForSpecifier = (
1082
+ source: string,
1083
+ specifier: string
1084
+ ): readonly string[] => {
1085
+ const clauses: string[] = [];
1086
+ let index = 0;
1087
+ while (index < source.length) {
1088
+ if (source.startsWith('//', index)) {
1089
+ index = skipLineComment(source, index);
1090
+ continue;
1091
+ }
1092
+ if (source.startsWith('/*', index)) {
1093
+ index = skipBlockComment(source, index);
1094
+ continue;
1095
+ }
1096
+ const char = source[index];
1097
+ if (char === '"' || char === "'" || char === '`') {
1098
+ index = skipQuotedLiteral(source, index);
1099
+ continue;
1100
+ }
1101
+ if (
1102
+ source.startsWith('import', index) &&
1103
+ !isIdentifierChar(source[index - 1]) &&
1104
+ !isIdentifierChar(source[index + 'import'.length])
1105
+ ) {
1106
+ const clause = staticImportClauseForSpecifier(source, index, specifier);
1107
+ if (clause) {
1108
+ clauses.push(clause);
1109
+ }
1110
+ index += 'import'.length;
1111
+ continue;
1112
+ }
1113
+ index += 1;
1114
+ }
1115
+
1116
+ return clauses;
1117
+ };
1118
+
1119
+ const namedImportBindings = (
1120
+ source: string,
1121
+ specifier: string,
1122
+ exportedName: string
1123
+ ): readonly string[] => {
1124
+ const namedBindings: string[] = [];
1125
+ const namespaceBindings: string[] = [];
1126
+ for (const clause of staticImportClausesForSpecifier(source, specifier)) {
1127
+ const code = maskSource(clause, { strings: false });
1128
+ const namespaceImport =
1129
+ /(?:^|,)\s*\*\s+as\s+(?<local>[A-Za-z_$][\w$]*)(?:\s*$|,)/u.exec(
1130
+ code
1131
+ )?.groups;
1132
+ if (namespaceImport?.['local']) {
1133
+ namespaceBindings.push(`${namespaceImport['local']}.${exportedName}`);
1134
+ }
1135
+
1136
+ const namedImports = /\{(?<imports>[\s\S]*?)\}/u.exec(code)?.groups?.[
1137
+ 'imports'
1138
+ ];
1139
+ if (!namedImports) {
1140
+ continue;
1141
+ }
1142
+
1143
+ for (const item of namedImports.split(',')) {
1144
+ const specifierText = item.trim();
1145
+ if (specifierText.length === 0 || specifierText.startsWith('type ')) {
1146
+ continue;
1147
+ }
1148
+
1149
+ const imported =
1150
+ /^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u.exec(
1151
+ specifierText
1152
+ )?.groups;
1153
+ if (imported?.['imported'] === exportedName) {
1154
+ namedBindings.push(imported['local'] ?? imported['imported']);
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ return [...new Set([...namedBindings, ...namespaceBindings])];
1160
+ };
1161
+
1162
+ const dynamicImportNamespaceBindings = (
1163
+ source: string,
1164
+ specifier: string
1165
+ ): readonly string[] => {
1166
+ const code = maskSource(source, { strings: false });
1167
+ const stringsMaskedCode = maskSource(source, { strings: true });
1168
+ const escapedSpecifier = escapeRegExp(specifier);
1169
+ const pattern = new RegExp(
1170
+ `\\bconst\\s+(?<local>[A-Za-z_$][\\w$]*)(?:\\s*:\\s*[^=;]+)?\\s*=\\s*await\\s+import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)`,
1171
+ 'gu'
1172
+ );
1173
+
1174
+ return [...code.matchAll(pattern)]
1175
+ .filter((match) =>
1176
+ matchStartsWithAnyKeyword(stringsMaskedCode, match, ['const'])
1177
+ )
1178
+ .map((match) => match.groups?.['local'])
1179
+ .filter((local): local is string => local !== undefined);
1180
+ };
1181
+
1182
+ const topLevelNamespaceAliases = (
1183
+ ast: AstNode,
1184
+ namespaceBindings: ReadonlySet<string>
1185
+ ): readonly string[] => {
1186
+ if (ast.type !== 'Program') {
1187
+ return [];
1188
+ }
1189
+
1190
+ const aliases = new Set(namespaceBindings);
1191
+ const body = (ast as unknown as { body?: readonly AstNode[] }).body ?? [];
1192
+ let changed = true;
1193
+ while (changed) {
1194
+ changed = false;
1195
+ for (const statement of body) {
1196
+ if (statement.type !== 'VariableDeclaration') {
1197
+ continue;
1198
+ }
1199
+ if (statement['kind'] !== 'const') {
1200
+ continue;
1201
+ }
1202
+ const declarations =
1203
+ (statement as unknown as { declarations?: readonly AstNode[] })
1204
+ .declarations ?? [];
1205
+ for (const declaration of declarations) {
1206
+ const declarator = declaration as unknown as {
1207
+ id?: AstNode;
1208
+ init?: AstNode;
1209
+ };
1210
+ const local = identifierName(declarator.id);
1211
+ const sourceBinding = identifierName(declarator.init);
1212
+ if (
1213
+ local &&
1214
+ sourceBinding &&
1215
+ aliases.has(sourceBinding) &&
1216
+ !aliases.has(local)
1217
+ ) {
1218
+ aliases.add(local);
1219
+ changed = true;
1220
+ }
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ return [...aliases].filter((binding) => !namespaceBindings.has(binding));
1226
+ };
1227
+
1228
+ interface DynamicNamedImportBinding {
1229
+ readonly imported: string;
1230
+ readonly local: string;
1231
+ }
1232
+
1233
+ const closingDestructuringBrace = (
1234
+ source: string,
1235
+ openingBrace: number
1236
+ ): number | undefined => {
1237
+ let depth = 0;
1238
+ for (let index = openingBrace; index < source.length; index += 1) {
1239
+ const skippedIndex = skipImportScanIgnoredToken(source, index);
1240
+ if (skippedIndex !== undefined) {
1241
+ index = skippedIndex - 1;
1242
+ continue;
1243
+ }
1244
+ const char = source[index];
1245
+ if (char === '{') {
1246
+ depth += 1;
1247
+ } else if (char === '}') {
1248
+ depth -= 1;
1249
+ if (depth === 0) {
1250
+ return index;
1251
+ }
1252
+ }
1253
+ }
1254
+ return undefined;
1255
+ };
1256
+
1257
+ const splitDestructuringBindings = (source: string): readonly string[] => {
1258
+ const bindings: string[] = [];
1259
+ let start = 0;
1260
+ let depth = 0;
1261
+ for (let index = 0; index < source.length; index += 1) {
1262
+ const skippedIndex = skipImportScanIgnoredToken(source, index);
1263
+ if (skippedIndex !== undefined) {
1264
+ index = skippedIndex - 1;
1265
+ continue;
1266
+ }
1267
+ const char = source[index];
1268
+ if (char === '{' || char === '[' || char === '(') {
1269
+ depth += 1;
1270
+ } else if (char === '}' || char === ']' || char === ')') {
1271
+ depth = Math.max(0, depth - 1);
1272
+ } else if (char === ',' && depth === 0) {
1273
+ bindings.push(source.slice(start, index));
1274
+ start = index + 1;
1275
+ }
1276
+ }
1277
+ bindings.push(source.slice(start));
1278
+ return bindings;
1279
+ };
1280
+
1281
+ const dynamicImportNamedBindings = (
1282
+ source: string,
1283
+ specifier: string
1284
+ ): readonly DynamicNamedImportBinding[] => {
1285
+ const code = maskSource(source, { strings: false });
1286
+ const stringsMaskedCode = maskSource(source, { strings: true });
1287
+ const escapedSpecifier = escapeRegExp(specifier);
1288
+ const bindings: DynamicNamedImportBinding[] = [];
1289
+ const declarationPattern = /\bconst\s*\{/gu;
1290
+ const assignmentPattern = new RegExp(
1291
+ `^\\s*(?::\\s*typeof\\s+import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\))?\\s*=\\s*(?:await\\s+)?import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)`,
1292
+ 'u'
1293
+ );
1294
+
1295
+ for (const match of code.matchAll(declarationPattern)) {
1296
+ if (!matchStartsWithAnyKeyword(stringsMaskedCode, match, ['const'])) {
1297
+ continue;
1298
+ }
1299
+
1300
+ const openingBrace = (match.index ?? 0) + match[0].lastIndexOf('{');
1301
+ const closingBrace = closingDestructuringBrace(code, openingBrace);
1302
+ if (
1303
+ closingBrace === undefined ||
1304
+ !assignmentPattern.test(code.slice(closingBrace + 1))
1305
+ ) {
1306
+ continue;
1307
+ }
1308
+
1309
+ const namedImports = code.slice(openingBrace + 1, closingBrace);
1310
+ for (const item of splitDestructuringBindings(namedImports)) {
1311
+ const specifierText = item.trim();
1312
+ if (specifierText.length === 0 || specifierText.startsWith('...')) {
1313
+ continue;
1314
+ }
1315
+
1316
+ const imported =
1317
+ /^(?<imported>[A-Za-z_$][\w$]*)(?:\s*:\s*(?<local>[A-Za-z_$][\w$]*))?(?:\s*=.*)?$/u.exec(
1318
+ specifierText
1319
+ )?.groups;
1320
+ if (imported?.['imported']) {
1321
+ bindings.push({
1322
+ imported: imported['imported'],
1323
+ local: imported['local'] ?? imported['imported'],
1324
+ });
1325
+ }
1326
+ }
1327
+ }
1328
+
1329
+ return bindings;
1330
+ };
1331
+
1332
+ const dynamicImportNamedLocals = (
1333
+ source: string,
1334
+ specifier: string,
1335
+ exportedName: string
1336
+ ): readonly string[] =>
1337
+ dynamicImportNamedBindings(source, specifier)
1338
+ .filter((binding) => binding.imported === exportedName)
1339
+ .map((binding) => binding.local);
1340
+
1341
+ interface LocalValueExport {
1342
+ readonly identifier: string;
1343
+ readonly sourcePath: string;
1344
+ }
1345
+
1346
+ interface LocalReexport {
1347
+ readonly identifier: string;
1348
+ readonly specifier: string;
1349
+ readonly typeOnly: boolean;
1350
+ }
1351
+
1352
+ interface LocalImport {
1353
+ readonly identifier: string;
1354
+ readonly specifier: string;
1355
+ readonly typeOnly: boolean;
1356
+ }
1357
+
1358
+ interface NamedExportItem {
1359
+ readonly local: string;
1360
+ readonly name: string;
1361
+ readonly typeOnly: boolean;
1362
+ }
1363
+
1364
+ const parseNamedExportItem = (
1365
+ item: string,
1366
+ declarationTypeOnly: boolean
1367
+ ): NamedExportItem | undefined => {
1368
+ const trimmedItem = item.trim();
1369
+ if (!trimmedItem) {
1370
+ return undefined;
1371
+ }
1372
+
1373
+ const itemTypeOnly = declarationTypeOnly || trimmedItem.startsWith('type ');
1374
+ const specifierText = trimmedItem.replace(/^type\s+/u, '');
1375
+ const exported =
1376
+ /^(?<local>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<name>[A-Za-z_$][\w$]*))?$/u.exec(
1377
+ specifierText
1378
+ )?.groups;
1379
+ if (!exported?.['local']) {
1380
+ return undefined;
1381
+ }
1382
+
1383
+ return {
1384
+ local: exported['local'],
1385
+ name: exported['name'] ?? exported['local'],
1386
+ typeOnly: itemTypeOnly,
1387
+ };
1388
+ };
1389
+
1390
+ const declaresValueBinding = (source: string, identifier: string): boolean => {
1391
+ const code = maskSource(source, { strings: true });
1392
+ const escapedIdentifier = escapeRegExp(identifier);
1393
+ return new RegExp(
1394
+ `\\b(?:export\\s+)?(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
1395
+ 'u'
1396
+ ).test(code);
1397
+ };
1398
+
1399
+ const declaresValueExport = (source: string, identifier: string): boolean => {
1400
+ const code = maskSource(source, { strings: true });
1401
+ const escapedIdentifier = escapeRegExp(identifier);
1402
+ return new RegExp(
1403
+ `\\bexport\\s+(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
1404
+ 'u'
1405
+ ).test(code);
1406
+ };
1407
+
1408
+ const sameFileValueExportLocal = (
1409
+ source: string,
1410
+ identifier: string
1411
+ ): string | undefined => {
1412
+ const code = maskSource(source, { strings: true });
1413
+ const pattern =
1414
+ /\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}(?!\s+from\b)/gu;
1415
+
1416
+ for (const match of code.matchAll(pattern)) {
1417
+ const declarationTypeOnly = Boolean(match.groups?.['typeOnly']);
1418
+ const namedExports = match.groups?.['exports'] ?? '';
1419
+ for (const item of namedExports.split(',')) {
1420
+ const exported = parseNamedExportItem(item, declarationTypeOnly);
1421
+ if (!exported || exported.typeOnly || exported.name !== identifier) {
1422
+ continue;
1423
+ }
1424
+
1425
+ return exported.local;
1426
+ }
1427
+ }
1428
+
1429
+ return undefined;
1430
+ };
1431
+
1432
+ const namedLocalImports = (
1433
+ source: string,
1434
+ identifier: string
1435
+ ): readonly LocalImport[] => {
1436
+ const code = maskSource(source, { strings: false });
1437
+ const stringsMaskedCode = maskSource(source, { strings: true });
1438
+ const imports: LocalImport[] = [];
1439
+ const pattern =
1440
+ /\bimport\s+(?<typeOnly>type\s+)?\{(?<imports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
1441
+
1442
+ for (const match of code.matchAll(pattern)) {
1443
+ if (!stringsMaskedCode.startsWith('import', match.index ?? 0)) {
1444
+ continue;
1445
+ }
1446
+
1447
+ const specifier = match.groups?.['specifier'];
1448
+ if (!specifier?.startsWith('.')) {
1449
+ continue;
1450
+ }
1451
+
1452
+ const namedImports = match.groups?.['imports'] ?? '';
1453
+ for (const item of namedImports.split(',')) {
1454
+ const trimmedItem = item.trim();
1455
+ if (!trimmedItem) {
1456
+ continue;
1457
+ }
1458
+
1459
+ const itemTypeOnly =
1460
+ Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
1461
+ const specifierText = trimmedItem.replace(/^type\s+/u, '');
1462
+ const imported =
1463
+ /^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u.exec(
1464
+ specifierText
1465
+ )?.groups;
1466
+ if (
1467
+ !imported?.['imported'] ||
1468
+ (imported['local'] ?? imported['imported']) !== identifier
1469
+ ) {
1470
+ continue;
1471
+ }
1472
+
1473
+ imports.push({
1474
+ identifier: imported['imported'],
1475
+ specifier,
1476
+ typeOnly: itemTypeOnly,
1477
+ });
1478
+ }
1479
+ }
1480
+
1481
+ return imports;
1482
+ };
1483
+
1484
+ const namedLocalReexports = (
1485
+ source: string,
1486
+ identifier: string
1487
+ ): readonly LocalReexport[] => {
1488
+ const code = maskSource(source, { strings: false });
1489
+ const stringsMaskedCode = maskSource(source, { strings: true });
1490
+ const exports: LocalReexport[] = [];
1491
+ const pattern =
1492
+ /\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
1493
+
1494
+ for (const match of code.matchAll(pattern)) {
1495
+ if (!matchStartsWithAnyKeyword(stringsMaskedCode, match, ['export'])) {
1496
+ continue;
1497
+ }
1498
+
1499
+ const specifier = match.groups?.['specifier'];
1500
+ if (!specifier?.startsWith('.')) {
1501
+ continue;
1502
+ }
1503
+
1504
+ const namedExports = match.groups?.['exports'] ?? '';
1505
+ for (const item of namedExports.split(',')) {
1506
+ const exported = parseNamedExportItem(
1507
+ item,
1508
+ Boolean(match.groups?.['typeOnly'])
1509
+ );
1510
+ if (!exported || exported.name !== identifier) {
1511
+ continue;
1512
+ }
1513
+
1514
+ exports.push({
1515
+ identifier: exported.local,
1516
+ specifier,
1517
+ typeOnly: exported.typeOnly,
1518
+ });
1519
+ }
1520
+ }
1521
+
1522
+ return exports;
1523
+ };
1524
+
1525
+ const starLocalReexports = (source: string): readonly LocalReexport[] => {
1526
+ const code = maskSource(source, { strings: false });
1527
+ const stringsMaskedCode = maskSource(source, { strings: true });
1528
+ return [
1529
+ ...code.matchAll(
1530
+ /\bexport\s+(?<typeOnly>type\s+)?\*\s+from\s+['"](?<specifier>[^'"]+)['"]/gu
1531
+ ),
1532
+ ]
1533
+ .filter((match) =>
1534
+ matchStartsWithAnyKeyword(stringsMaskedCode, match, ['export'])
1535
+ )
1536
+ .map((match) => ({
1537
+ identifier: '',
1538
+ specifier: match.groups?.['specifier'] ?? '',
1539
+ typeOnly: Boolean(match.groups?.['typeOnly']),
1540
+ }))
1541
+ .filter((entry) => entry.specifier.startsWith('.'));
1542
+ };
1543
+
1544
+ const resolveLocalModuleSpecifier = (
1545
+ sourcePath: string,
1546
+ specifier: string
1547
+ ): string | undefined => {
1548
+ const basePath = resolve(dirname(sourcePath), specifier);
1549
+ const candidates = [
1550
+ basePath,
1551
+ basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.ts` : undefined,
1552
+ basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.tsx` : undefined,
1553
+ basePath.endsWith('.mjs') ? `${basePath.slice(0, -4)}.mts` : undefined,
1554
+ `${basePath}.ts`,
1555
+ `${basePath}.tsx`,
1556
+ join(basePath, 'index.ts'),
1557
+ join(basePath, 'index.tsx'),
1558
+ ].filter((candidate): candidate is string => candidate !== undefined);
1559
+
1560
+ return candidates.find((candidate) => existsSync(candidate));
1561
+ };
1562
+
1563
+ const resolveLocalValueExport = (
1564
+ sourcePath: string,
1565
+ identifier: string,
1566
+ visited = new Set<string>()
1567
+ ): LocalValueExport | undefined => {
1568
+ const normalizedSourcePath = normalizeRealPath(sourcePath);
1569
+ const visitKey = `${normalizedSourcePath}:${identifier}`;
1570
+ if (visited.has(visitKey)) {
1571
+ return undefined;
1572
+ }
1573
+ visited.add(visitKey);
1574
+
1575
+ let source: string;
1576
+ try {
1577
+ source = readFileSync(normalizedSourcePath, 'utf8');
1578
+ } catch {
1579
+ return undefined;
1580
+ }
1581
+
1582
+ if (declaresValueExport(source, identifier)) {
1583
+ return { identifier, sourcePath: normalizedSourcePath };
1584
+ }
1585
+
1586
+ const sameFileLocal = sameFileValueExportLocal(source, identifier);
1587
+ if (sameFileLocal && declaresValueBinding(source, sameFileLocal)) {
1588
+ return { identifier: sameFileLocal, sourcePath: normalizedSourcePath };
1589
+ }
1590
+ if (sameFileLocal) {
1591
+ for (const localImport of namedLocalImports(source, sameFileLocal)) {
1592
+ if (localImport.typeOnly) {
1593
+ continue;
1594
+ }
1595
+ const targetPath = resolveLocalModuleSpecifier(
1596
+ normalizedSourcePath,
1597
+ localImport.specifier
1598
+ );
1599
+ const resolved =
1600
+ targetPath &&
1601
+ resolveLocalValueExport(targetPath, localImport.identifier, visited);
1602
+ if (resolved) {
1603
+ return resolved;
1604
+ }
1605
+ }
1606
+ }
1607
+
1608
+ for (const reexport of namedLocalReexports(source, identifier)) {
1609
+ if (reexport.typeOnly) {
1610
+ continue;
1611
+ }
1612
+ const targetPath = resolveLocalModuleSpecifier(
1613
+ normalizedSourcePath,
1614
+ reexport.specifier
1615
+ );
1616
+ const resolved =
1617
+ targetPath &&
1618
+ resolveLocalValueExport(targetPath, reexport.identifier, visited);
1619
+ if (resolved) {
1620
+ return resolved;
1621
+ }
1622
+ }
1623
+
1624
+ for (const reexport of starLocalReexports(source)) {
1625
+ if (reexport.typeOnly) {
1626
+ continue;
1627
+ }
1628
+ const targetPath = resolveLocalModuleSpecifier(
1629
+ normalizedSourcePath,
1630
+ reexport.specifier
1631
+ );
1632
+ const resolved =
1633
+ targetPath && resolveLocalValueExport(targetPath, identifier, visited);
1634
+ if (resolved) {
1635
+ return resolved;
1636
+ }
1637
+ }
1638
+
1639
+ return undefined;
1640
+ };
1641
+
1642
+ const findClosingParen = (source: string, openIndex: number): number => {
1643
+ let depth = 0;
1644
+ for (let index = openIndex; index < source.length; index += 1) {
1645
+ if (source[index] === '(') {
1646
+ depth += 1;
1647
+ continue;
1648
+ }
1649
+ if (source[index] !== ')') {
1650
+ continue;
1651
+ }
1652
+ depth -= 1;
1653
+ if (depth === 0) {
1654
+ return index;
1655
+ }
1656
+ }
1657
+
1658
+ return -1;
1659
+ };
1660
+
1661
+ const previousNonWhitespace = (source: string, index: number): string => {
1662
+ let previousIndex = index - 1;
1663
+ while (previousIndex >= 0 && /\s/u.test(source[previousIndex] ?? '')) {
1664
+ previousIndex -= 1;
1665
+ }
1666
+ return source[previousIndex] ?? '';
1667
+ };
1668
+
1669
+ const containsCall = (source: string, identifier: string): boolean => {
1670
+ const escapedIdentifier = escapeRegExp(identifier);
1671
+ const callPattern = new RegExp(`${escapedIdentifier}\\s*\\(`, 'gu');
1672
+ for (const match of source.matchAll(callPattern)) {
1673
+ const index = match.index ?? 0;
1674
+ if (
1675
+ !isIdentifierChar(source[index - 1]) &&
1676
+ previousNonWhitespace(source, index) !== '.'
1677
+ ) {
1678
+ return true;
1679
+ }
1680
+ }
1681
+ return false;
1682
+ };
1683
+
1684
+ const promiseMethodNames = new Set(['then', 'catch', 'finally']);
1685
+
1686
+ const inlineDynamicImportMemberIsCalled = (
1687
+ source: string,
1688
+ specifier: string
1689
+ ): boolean => {
1690
+ const searchableCode = maskSource(source, { strings: false });
1691
+ const codePositions = maskSource(source, { strings: true });
1692
+ const escapedSpecifier = escapeRegExp(specifier);
1693
+ const pattern = new RegExp(
1694
+ `\\(\\s*await\\s+import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)\\s*\\)\\s*\\.\\s*(?<member>[A-Za-z_$][\\w$]*)\\s*\\(`,
1695
+ 'gu'
1696
+ );
1697
+ return [...searchableCode.matchAll(pattern)].some(
1698
+ (match) =>
1699
+ codePositions[match.index ?? 0] !== ' ' &&
1700
+ !promiseMethodNames.has(match.groups?.['member'] ?? '')
1701
+ );
1702
+ };
1703
+
1704
+ interface RuntimeImportBindings {
1705
+ readonly direct: readonly string[];
1706
+ readonly namespaces: readonly string[];
1707
+ }
1708
+
1709
+ const staticRuntimeImportBindings = (
1710
+ source: string,
1711
+ specifier: string
1712
+ ): RuntimeImportBindings => {
1713
+ const direct = new Set<string>();
1714
+ const namespaces = new Set<string>();
1715
+ for (const clause of staticImportClausesForSpecifier(source, specifier)) {
1716
+ const importCode = maskSource(clause, { strings: false });
1717
+ const namespaceBinding =
1718
+ /(?:^|,)\s*\*\s+as\s+(?<local>[A-Za-z_$][\w$]*)(?:\s*$|,)/u.exec(
1719
+ importCode
1720
+ )?.groups?.['local'];
1721
+ if (namespaceBinding) {
1722
+ namespaces.add(namespaceBinding);
1723
+ }
1724
+
1725
+ const namedImports = /\{(?<imports>[\s\S]*?)\}/u.exec(importCode)?.groups?.[
1726
+ 'imports'
1727
+ ];
1728
+ for (const item of namedImports?.split(',') ?? []) {
1729
+ const trimmedItem = item.trim();
1730
+ const imported =
1731
+ /^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u.exec(
1732
+ trimmedItem.replace(/^type\s+/u, '')
1733
+ )?.groups;
1734
+ if (!trimmedItem.startsWith('type ') && imported?.['imported']) {
1735
+ direct.add(imported['local'] ?? imported['imported']);
1736
+ }
1737
+ }
1738
+
1739
+ const defaultBinding = /^(?<local>[A-Za-z_$][\w$]*)(?:\s*,|\s*$)/u.exec(
1740
+ importCode.trim()
1741
+ )?.groups?.['local'];
1742
+ if (defaultBinding) {
1743
+ direct.add(defaultBinding);
1744
+ }
1745
+ }
1746
+
1747
+ return { direct: [...direct], namespaces: [...namespaces] };
1748
+ };
1749
+
1750
+ interface RunnerCall {
1751
+ readonly arguments: readonly string[];
1752
+ readonly argumentNodes: readonly AstNode[];
1753
+ }
1754
+
1755
+ const namespacedCalleeName = (
1756
+ callee: AstNode
1757
+ ): { readonly property: string; readonly receiver: string } | undefined => {
1758
+ if (
1759
+ (callee.type !== 'MemberExpression' &&
1760
+ callee.type !== 'StaticMemberExpression') ||
1761
+ callee['computed'] === true
1762
+ ) {
1763
+ return undefined;
1764
+ }
1765
+ const receiver = identifierName(callee['object'] as AstNode | undefined);
1766
+ const property = identifierName(callee['property'] as AstNode | undefined);
1767
+ return receiver && property ? { property, receiver } : undefined;
1768
+ };
1769
+
1770
+ interface ProvenBindingCall {
1771
+ readonly arguments: readonly AstNode[];
1772
+ readonly end: number;
1773
+ readonly start: number;
1774
+ }
1775
+
1776
+ const provenBindingCalls = (
1777
+ ast: AstNode,
1778
+ binding: string
1779
+ ): readonly ProvenBindingCall[] => {
1780
+ const calls: ProvenBindingCall[] = [];
1781
+ walkWithScopes(ast, (node, scopes) => {
1782
+ if (node.type !== 'CallExpression') {
1783
+ return;
1784
+ }
1785
+ const callee = node['callee'] as AstNode | undefined;
1786
+ if (!callee) {
1787
+ return;
1788
+ }
1789
+
1790
+ const member = namespacedCalleeName(callee);
1791
+ const bare = identifierName(callee);
1792
+ const matches = member
1793
+ ? `${member.receiver}.${member.property}` === binding &&
1794
+ !isShadowed(member.receiver, scopes)
1795
+ : bare === binding && !isShadowed(binding, scopes);
1796
+ if (!matches) {
1797
+ return;
1798
+ }
1799
+
1800
+ calls.push({
1801
+ arguments:
1802
+ (node as unknown as { arguments?: readonly AstNode[] }).arguments ?? [],
1803
+ end: node.end,
1804
+ start: node.start,
1805
+ });
1806
+ });
1807
+ return calls;
1808
+ };
1809
+
1810
+ const provenNamespaceIsCalled = (ast: AstNode, binding: string): boolean => {
1811
+ let called = false;
1812
+ walkWithScopes(ast, (node, scopes) => {
1813
+ if (called || node.type !== 'CallExpression') {
1814
+ return;
1815
+ }
1816
+ const callee = node['callee'] as AstNode | undefined;
1817
+ const member = callee && namespacedCalleeName(callee);
1818
+ if (
1819
+ member?.receiver === binding &&
1820
+ !promiseMethodNames.has(member.property) &&
1821
+ !isShadowed(binding, scopes)
1822
+ ) {
1823
+ called = true;
1824
+ }
1825
+ });
1826
+ return called;
1827
+ };
1828
+
1829
+ const runnerCallArguments = (
1830
+ source: string,
1831
+ runner: string,
1832
+ ast: AstNode
1833
+ ): readonly RunnerCall[] =>
1834
+ provenBindingCalls(ast, runner).map((call) => ({
1835
+ argumentNodes: call.arguments,
1836
+ arguments: call.arguments.map((argument) =>
1837
+ source.slice(argument.start, argument.end)
1838
+ ),
1839
+ }));
1840
+
1841
+ const argumentContainsProvenCall = (
1842
+ argument: AstNode | undefined,
1843
+ ast: AstNode,
1844
+ binding: string
1845
+ ): boolean =>
1846
+ argument !== undefined &&
1847
+ provenBindingCalls(ast, binding).some(
1848
+ (call) => call.start >= argument.start && call.end <= argument.end
1849
+ );
1850
+
1851
+ const importedRuntimeBindingIsCalled = (
1852
+ source: string,
1853
+ specifier: string,
1854
+ ast: AstNode
1855
+ ): boolean => {
1856
+ const staticBindings = staticRuntimeImportBindings(source, specifier);
1857
+ const directBindings = new Set([
1858
+ ...staticBindings.direct,
1859
+ ...dynamicImportNamedBindings(source, specifier).map(
1860
+ (binding) => binding.local
1861
+ ),
1862
+ ]);
1863
+ if (
1864
+ [...directBindings].some(
1865
+ (binding) => provenBindingCalls(ast, binding).length > 0
1866
+ )
1867
+ ) {
1868
+ return true;
1869
+ }
1870
+
1871
+ const namespaceBindings = new Set([
1872
+ ...staticBindings.namespaces,
1873
+ ...dynamicImportNamespaceBindings(source, specifier),
1874
+ ]);
1875
+ for (const alias of topLevelNamespaceAliases(ast, namespaceBindings)) {
1876
+ namespaceBindings.add(alias);
1877
+ }
1878
+ return (
1879
+ [...namespaceBindings].some((binding) =>
1880
+ provenNamespaceIsCalled(ast, binding)
1881
+ ) || inlineDynamicImportMemberIsCalled(source, specifier)
1882
+ );
1883
+ };
1884
+
1885
+ const isSentinelAdapterArgument = (argument: string): boolean =>
1886
+ /^(?:undefined|null|void\s*(?:0|\(0\)))$/u.test(argument.trim());
1887
+
1888
+ const runnerInvokesCasesFactory = (
1889
+ source: string,
1890
+ ast: AstNode,
1891
+ runner: string,
1892
+ casesFactory: string
1893
+ ): boolean =>
1894
+ runnerCallArguments(source, runner, ast).some((call) => {
1895
+ const args = call.arguments;
1896
+ const adapterArgument = args[0]?.trim();
1897
+ return (
1898
+ adapterArgument !== undefined &&
1899
+ adapterArgument.length > 0 &&
1900
+ !isSentinelAdapterArgument(adapterArgument) &&
1901
+ !argumentContainsProvenCall(call.argumentNodes[0], ast, casesFactory) &&
1902
+ call.argumentNodes
1903
+ .slice(1)
1904
+ .some((argument) =>
1905
+ argumentContainsProvenCall(argument, ast, casesFactory)
1906
+ )
1907
+ );
1908
+ });
1909
+
1910
+ const runnerInvokedWithAdapterArgument = (
1911
+ source: string,
1912
+ ast: AstNode,
1913
+ runner: string,
1914
+ casesFactories: readonly string[] = []
1915
+ ): boolean =>
1916
+ runnerCallArguments(source, runner, ast).some((call) => {
1917
+ const args = call.arguments;
1918
+ const adapterArgument = args[0]?.trim();
1919
+ return (
1920
+ adapterArgument !== undefined &&
1921
+ adapterArgument.length > 0 &&
1922
+ !isSentinelAdapterArgument(adapterArgument) &&
1923
+ casesFactories.every(
1924
+ (casesFactory) =>
1925
+ !argumentContainsProvenCall(call.argumentNodes[0], ast, casesFactory)
1926
+ )
1927
+ );
1928
+ });
1929
+
1930
+ const ownerRunnerDefaultsCasesFactory = (
1931
+ targetEntry: AdapterTargetCatalogEntry
1932
+ ): boolean => {
1933
+ const { conformance, testingExportTarget } = targetEntry;
1934
+ if (!conformance || !testingExportTarget) {
1935
+ return false;
1936
+ }
1937
+
1938
+ const runnerExport = resolveLocalValueExport(
1939
+ testingExportTarget,
1940
+ conformance.runner
1941
+ );
1942
+ if (!runnerExport) {
1943
+ return false;
1944
+ }
1945
+ const casesFactoryExport = resolveLocalValueExport(
1946
+ testingExportTarget,
1947
+ conformance.casesFactory
1948
+ );
1949
+ const casesFactoryIdentifiers = [
1950
+ conformance.casesFactory,
1951
+ casesFactoryExport?.identifier,
1952
+ ].filter((identifier): identifier is string => identifier !== undefined);
1953
+
1954
+ let source: string;
1955
+ try {
1956
+ source = readFileSync(runnerExport.sourcePath, 'utf8');
1957
+ } catch {
1958
+ return false;
1959
+ }
1960
+
1961
+ const code = maskSource(source, { strings: true });
1962
+ const escapedRunner = escapeRegExp(runnerExport.identifier);
1963
+ const declarationPatterns = [
1964
+ new RegExp(
1965
+ `\\b(?:export\\s+)?(?:async\\s+)?function\\s+${escapedRunner}\\b`,
1966
+ 'gu'
1967
+ ),
1968
+ new RegExp(
1969
+ `\\b(?:export\\s+)?(?:const|let|var)\\s+${escapedRunner}\\b\\s*(?::[^=;]*)?=`,
1970
+ 'gu'
1971
+ ),
1972
+ ];
1973
+
1974
+ for (const pattern of declarationPatterns) {
1975
+ for (const match of code.matchAll(pattern)) {
1976
+ const openIndex = code.indexOf('(', (match.index ?? 0) + match[0].length);
1977
+ if (openIndex === -1) {
1978
+ continue;
1979
+ }
1980
+ const closeIndex = findClosingParen(code, openIndex);
1981
+ if (closeIndex === -1) {
1982
+ continue;
1983
+ }
1984
+
1985
+ const params = code.slice(openIndex + 1, closeIndex);
1986
+ if (
1987
+ params.includes('=') &&
1988
+ casesFactoryIdentifiers.some((identifier) =>
1989
+ containsCall(params, identifier)
1990
+ )
1991
+ ) {
1992
+ return true;
1993
+ }
1994
+ }
1995
+ }
1996
+
1997
+ return false;
1998
+ };
1999
+
2000
+ const runnerBindingProvesConformance = (
2001
+ source: string,
2002
+ ast: AstNode,
2003
+ targetEntry: AdapterTargetCatalogEntry,
2004
+ runnerBinding: string,
2005
+ casesFactoryBindings: readonly string[]
2006
+ ): boolean => {
2007
+ for (const casesFactoryBinding of casesFactoryBindings) {
2008
+ if (
2009
+ runnerInvokesCasesFactory(source, ast, runnerBinding, casesFactoryBinding)
2010
+ ) {
2011
+ return true;
2012
+ }
2013
+ }
2014
+
2015
+ return (
2016
+ ownerRunnerDefaultsCasesFactory(targetEntry) &&
2017
+ runnerInvokedWithAdapterArgument(
2018
+ source,
2019
+ ast,
2020
+ runnerBinding,
2021
+ casesFactoryBindings
2022
+ )
2023
+ );
2024
+ };
2025
+
2026
+ const provesConformance = (
2027
+ source: string,
2028
+ targetEntry: AdapterTargetCatalogEntry
2029
+ ): boolean => {
2030
+ const { conformance, testingImport } = targetEntry;
2031
+ if (
2032
+ !testingImport ||
2033
+ !importsSpecifier(source, testingImport, {
2034
+ includeReExports: false,
2035
+ requireImportBinding: true,
2036
+ })
2037
+ ) {
2038
+ return false;
2039
+ }
2040
+
2041
+ const ast = parse('adapter-conformance.ts', source);
2042
+ if (!ast) {
2043
+ return false;
2044
+ }
2045
+
2046
+ if (!conformance) {
2047
+ return importedRuntimeBindingIsCalled(source, testingImport, ast);
2048
+ }
2049
+
2050
+ const runnerBindings = new Set(
2051
+ namedImportBindings(source, testingImport, conformance.runner)
2052
+ );
2053
+ const casesFactoryBindings = new Set(
2054
+ namedImportBindings(source, testingImport, conformance.casesFactory)
2055
+ );
2056
+ const dynamicRunnerBindings = dynamicImportNamedLocals(
2057
+ source,
2058
+ testingImport,
2059
+ conformance.runner
2060
+ );
2061
+ for (const dynamicRunnerBinding of dynamicRunnerBindings) {
2062
+ runnerBindings.add(dynamicRunnerBinding);
2063
+ }
2064
+ const dynamicCasesFactoryBindings = dynamicImportNamedLocals(
2065
+ source,
2066
+ testingImport,
2067
+ conformance.casesFactory
2068
+ );
2069
+ for (const dynamicCasesFactoryBinding of dynamicCasesFactoryBindings) {
2070
+ casesFactoryBindings.add(dynamicCasesFactoryBinding);
2071
+ }
2072
+ for (const namespaceBinding of dynamicImportNamespaceBindings(
2073
+ source,
2074
+ testingImport
2075
+ )) {
2076
+ runnerBindings.add(`${namespaceBinding}.${conformance.runner}`);
2077
+ casesFactoryBindings.add(`${namespaceBinding}.${conformance.casesFactory}`);
2078
+ }
2079
+
2080
+ const namespaceBindings = new Set(
2081
+ [...runnerBindings]
2082
+ .filter((binding) => binding.endsWith(`.${conformance.runner}`))
2083
+ .map((binding) => binding.slice(0, -conformance.runner.length - 1))
2084
+ );
2085
+ for (const alias of topLevelNamespaceAliases(ast, namespaceBindings)) {
2086
+ runnerBindings.add(`${alias}.${conformance.runner}`);
2087
+ casesFactoryBindings.add(`${alias}.${conformance.casesFactory}`);
2088
+ }
2089
+
2090
+ const allCasesFactoryBindings = [...casesFactoryBindings];
2091
+ return [...runnerBindings].some((runnerBinding) =>
2092
+ runnerBindingProvesConformance(
2093
+ source,
2094
+ ast,
2095
+ targetEntry,
2096
+ runnerBinding,
2097
+ allCasesFactoryBindings
2098
+ )
2099
+ );
2100
+ };
2101
+
2102
+ const pathsProvingConformance = (
2103
+ sourceFiles: readonly string[],
2104
+ targetEntry: AdapterTargetCatalogEntry
2105
+ ): readonly string[] =>
2106
+ sourceFiles.filter((filePath) =>
2107
+ provesConformance(readFileSync(filePath, 'utf8'), targetEntry)
2108
+ );
2109
+
2110
+ const isTestFile = (filePath: string): boolean => {
2111
+ const normalizedPath = normalizePath(filePath);
2112
+ return (
2113
+ normalizedPath.includes('/__tests__/') ||
2114
+ normalizedPath.endsWith('.test.ts') ||
2115
+ normalizedPath.endsWith('.test-d.ts')
2116
+ );
2117
+ };
2118
+
2119
+ const assertPackageExports = (
2120
+ workspace: WorkspacePackage,
2121
+ diagnostics: AdapterCheckDiagnostic[]
2122
+ ): void => {
2123
+ const packageName = workspace.manifest.name as string;
2124
+ for (const key of ['.', './package.json'] as const) {
2125
+ if (hasResolvableExport(workspace, key)) {
2126
+ continue;
2127
+ }
2128
+ diagnostics.push(
2129
+ diagnostic(
2130
+ workspace.packageJsonPath,
2131
+ packageName,
2132
+ 'missing-package-export',
2133
+ `${packageName} must export "${key}" so adapter kit and consumers can resolve it.`
2134
+ )
2135
+ );
2136
+ }
2137
+ };
2138
+
2139
+ const assertDependencyDirection = (
2140
+ workspace: WorkspacePackage,
2141
+ targetEntry: AdapterTargetCatalogEntry,
2142
+ diagnostics: AdapterCheckDiagnostic[]
2143
+ ): void => {
2144
+ const packageName = workspace.manifest.name as string;
2145
+ const dependencies = dependencyMap(workspace.manifest.dependencies);
2146
+ const devDependencies = dependencyMap(workspace.manifest.devDependencies);
2147
+ const optionalDependencies = dependencyMap(
2148
+ workspace.manifest.optionalDependencies
2149
+ );
2150
+ const peerDependencies = dependencyMap(workspace.manifest.peerDependencies);
2151
+
2152
+ if (
2153
+ Object.hasOwn(dependencies, targetEntry.ownerPackage) ||
2154
+ Object.hasOwn(optionalDependencies, targetEntry.ownerPackage)
2155
+ ) {
2156
+ diagnostics.push(
2157
+ diagnostic(
2158
+ workspace.packageJsonPath,
2159
+ packageName,
2160
+ 'dependency-direction',
2161
+ `${packageName} must peer-depend on ${targetEntry.ownerPackage}; runtime dependencies invert the adapter boundary.`,
2162
+ targetEntry.target,
2163
+ 'extracted'
2164
+ )
2165
+ );
2166
+ }
2167
+
2168
+ if (Object.hasOwn(devDependencies, targetEntry.ownerPackage)) {
2169
+ diagnostics.push(
2170
+ diagnostic(
2171
+ workspace.packageJsonPath,
2172
+ packageName,
2173
+ 'dependency-direction',
2174
+ `${packageName} must not hide ${targetEntry.ownerPackage} in devDependencies; declare the owner as a peer dependency.`,
2175
+ targetEntry.target,
2176
+ 'extracted'
2177
+ )
2178
+ );
2179
+ }
2180
+
2181
+ if (!Object.hasOwn(peerDependencies, targetEntry.ownerPackage)) {
2182
+ diagnostics.push(
2183
+ diagnostic(
2184
+ workspace.packageJsonPath,
2185
+ packageName,
2186
+ 'dependency-direction',
2187
+ `${packageName} must declare ${targetEntry.ownerPackage} in peerDependencies for extracted adapter placement.`,
2188
+ targetEntry.target,
2189
+ 'extracted'
2190
+ )
2191
+ );
2192
+ }
2193
+ };
2194
+
2195
+ const assertToolingBoundary = (
2196
+ workspace: WorkspacePackage,
2197
+ sourceFiles: readonly string[],
2198
+ diagnostics: AdapterCheckDiagnostic[]
2199
+ ): void => {
2200
+ const packageName = workspace.manifest.name as string;
2201
+ if (runtimeDependencyNames(workspace.manifest).has(adapterKitPackageName)) {
2202
+ diagnostics.push(
2203
+ diagnostic(
2204
+ workspace.packageJsonPath,
2205
+ packageName,
2206
+ 'tooling-boundary',
2207
+ `${packageName} must not depend on ${adapterKitPackageName}; adapter kit stays out of runtime adapter packages.`
2208
+ )
2209
+ );
2210
+ }
2211
+
2212
+ const runtimeSourceFiles = sourceFiles.filter(
2213
+ (sourceFile) => !isTestFile(sourceFile)
2214
+ );
2215
+ const toolingImportPaths = pathsImporting(
2216
+ runtimeSourceFiles,
2217
+ adapterKitPackageName
2218
+ );
2219
+ for (const sourcePath of toolingImportPaths) {
2220
+ diagnostics.push(
2221
+ diagnostic(
2222
+ workspace.packageJsonPath,
2223
+ packageName,
2224
+ 'tooling-boundary',
2225
+ `${packageName} imports ${adapterKitPackageName} from ${normalizePath(relative(workspace.packageRoot, sourcePath))}; adapters must not import the adapter kit engine.`
2226
+ )
2227
+ );
2228
+ }
2229
+ };
2230
+
2231
+ const sourceFilesForCandidate = (
2232
+ workspace: WorkspacePackage,
2233
+ candidate: AdapterCheckCandidate,
2234
+ sourceFiles: readonly string[]
2235
+ ): readonly string[] => {
2236
+ if (!candidate.exportKey) {
2237
+ return sourceFiles;
2238
+ }
2239
+
2240
+ const exportTarget = resolvableExportTarget(workspace, candidate.exportKey);
2241
+ if (!exportTarget) {
2242
+ return sourceFiles;
2243
+ }
2244
+
2245
+ const normalizedTarget = normalizePath(exportTarget);
2246
+ const targetDir = normalizePath(dirname(normalizedTarget));
2247
+ if (normalizedTarget.endsWith('/index.ts')) {
2248
+ return sourceFiles.filter((sourceFile) =>
2249
+ normalizePath(sourceFile).startsWith(`${targetDir}/`)
2250
+ );
2251
+ }
2252
+
2253
+ const targetStem = normalizedTarget.replace(/\.ts$/u, '');
2254
+ return sourceFiles.filter((sourceFile) => {
2255
+ const normalizedSourceFile = normalizePath(sourceFile);
2256
+ return (
2257
+ normalizedSourceFile === normalizedTarget ||
2258
+ normalizedSourceFile.startsWith(`${targetStem}.`) ||
2259
+ normalizedSourceFile.startsWith(`${targetStem}/`)
2260
+ );
2261
+ });
2262
+ };
2263
+
2264
+ const checkAdapterPackage = (
2265
+ workspace: WorkspacePackage,
2266
+ targetById: ReadonlyMap<string, AdapterTargetCatalogEntry>
2267
+ ): {
2268
+ readonly diagnostics: readonly AdapterCheckDiagnostic[];
2269
+ readonly subjects: readonly AdapterCheckSubject[];
2270
+ } => {
2271
+ const packageName = workspace.manifest.name as string;
2272
+ const diagnostics: AdapterCheckDiagnostic[] = [];
2273
+ const placement = placementForWorkspace(workspace.workspacePath);
2274
+ const metadata = trailAdapterMetadata(workspace.manifest);
2275
+ const subpathMetadata = trailSubpathAdapterMetadata(workspace.manifest);
2276
+
2277
+ if ((!placement || metadata === undefined) && subpathMetadata === undefined) {
2278
+ return { diagnostics: [], subjects: [] };
2279
+ }
2280
+
2281
+ assertPackageExports(workspace, diagnostics);
2282
+ const sourceFiles = collectSourceFiles(join(workspace.packageRoot, 'src'));
2283
+ assertToolingBoundary(workspace, sourceFiles, diagnostics);
2284
+
2285
+ const subjects: AdapterCheckSubject[] = [];
2286
+ const checkCandidate = (
2287
+ candidate: AdapterCheckCandidate
2288
+ ): AdapterCheckSubject | undefined => {
2289
+ if (
2290
+ candidate.exportKey &&
2291
+ !hasResolvableExport(workspace, candidate.exportKey)
2292
+ ) {
2293
+ diagnostics.push(
2294
+ diagnostic(
2295
+ workspace.packageJsonPath,
2296
+ candidate.packageName,
2297
+ 'missing-package-export',
2298
+ `${packageName} must export "${candidate.exportKey}" so ${candidate.packageName} can be resolved as a subpath adapter.`,
2299
+ candidate.metadata.target,
2300
+ candidate.placement
2301
+ )
2302
+ );
2303
+ }
2304
+
2305
+ const targetEntry = targetById.get(candidate.metadata.target);
2306
+ if (!targetEntry) {
2307
+ diagnostics.push(
2308
+ diagnostic(
2309
+ workspace.packageJsonPath,
2310
+ candidate.packageName,
2311
+ 'unknown-adapter-target',
2312
+ `${candidate.packageName} declares unknown adapter target "${candidate.metadata.target}".`,
2313
+ candidate.metadata.target,
2314
+ candidate.placement
2315
+ )
2316
+ );
2317
+ return undefined;
2318
+ }
2319
+
2320
+ if (
2321
+ candidate.placement === 'subpath' &&
2322
+ targetEntry.ownerPackage !== packageName
2323
+ ) {
2324
+ diagnostics.push(
2325
+ diagnostic(
2326
+ workspace.packageJsonPath,
2327
+ candidate.packageName,
2328
+ 'invalid-adapter-metadata',
2329
+ `${candidate.packageName} declares target "${targetEntry.target}", but subpath adapters must live in the owner package ${targetEntry.ownerPackage}.`,
2330
+ targetEntry.target,
2331
+ candidate.placement
2332
+ )
2333
+ );
2334
+ return undefined;
2335
+ }
2336
+
2337
+ if (!targetEntry.placements.includes(candidate.placement)) {
2338
+ diagnostics.push(
2339
+ diagnostic(
2340
+ workspace.packageJsonPath,
2341
+ candidate.packageName,
2342
+ 'unsupported-placement',
2343
+ `${targetEntry.ownerPackage}:${targetEntry.target} does not support ${candidate.placement} adapter placement.`,
2344
+ targetEntry.target,
2345
+ candidate.placement
2346
+ )
2347
+ );
2348
+ }
2349
+
2350
+ if (candidate.placement === 'extracted') {
2351
+ assertDependencyDirection(workspace, targetEntry, diagnostics);
2352
+ }
2353
+
2354
+ const { conformance, testingImport } = targetEntry;
2355
+ const candidateSourceFiles = sourceFilesForCandidate(
2356
+ workspace,
2357
+ candidate,
2358
+ sourceFiles
2359
+ );
2360
+ const conformanceTestPaths = testingImport
2361
+ ? pathsProvingConformance(
2362
+ candidateSourceFiles.filter(isTestFile),
2363
+ targetEntry
2364
+ )
2365
+ : [];
2366
+
2367
+ if (!testingImport) {
2368
+ diagnostics.push(
2369
+ diagnostic(
2370
+ workspace.packageJsonPath,
2371
+ candidate.packageName,
2372
+ 'missing-owner-conformance',
2373
+ `${targetEntry.ownerPackage}:${targetEntry.target} must declare testingImport before adapters can prove conformance.`,
2374
+ targetEntry.target,
2375
+ candidate.placement
2376
+ )
2377
+ );
2378
+ } else if (conformanceTestPaths.length === 0) {
2379
+ const conformanceHint = conformance
2380
+ ? ` and call ${conformance.runner}(adapter, ${conformance.casesFactory}(...))`
2381
+ : '';
2382
+ diagnostics.push(
2383
+ diagnostic(
2384
+ workspace.packageJsonPath,
2385
+ candidate.packageName,
2386
+ 'missing-conformance',
2387
+ `${candidate.packageName} must import ${testingImport} from a conformance test${conformanceHint}.`,
2388
+ targetEntry.target,
2389
+ candidate.placement
2390
+ )
2391
+ );
2392
+ }
2393
+
2394
+ return {
2395
+ conformanceTestPaths,
2396
+ key: candidate.key,
2397
+ ownerPackage: targetEntry.ownerPackage,
2398
+ packageJsonPath: workspace.packageJsonPath,
2399
+ packageName: candidate.packageName,
2400
+ packageRoot: workspace.packageRoot,
2401
+ placement: candidate.placement,
2402
+ target: targetEntry.target,
2403
+ targetKey: targetEntry.key,
2404
+ ...(conformance?.adapterType
2405
+ ? { adapterType: conformance.adapterType }
2406
+ : {}),
2407
+ ...(testingImport ? { testingImport } : {}),
2408
+ };
2409
+ };
2410
+
2411
+ if (placement && metadata !== undefined) {
2412
+ if (metadata === null) {
2413
+ diagnostics.push(
2414
+ diagnostic(
2415
+ workspace.packageJsonPath,
2416
+ packageName,
2417
+ 'invalid-adapter-metadata',
2418
+ `${packageName} must declare trails.adapter as an object with a kebab-case target string.`,
2419
+ undefined,
2420
+ placement
2421
+ )
2422
+ );
2423
+ } else {
2424
+ const subject = checkCandidate({
2425
+ key: packageName,
2426
+ metadata,
2427
+ packageName,
2428
+ placement,
2429
+ });
2430
+ if (subject) {
2431
+ subjects.push(subject);
2432
+ }
2433
+ }
2434
+ }
2435
+
2436
+ if (subpathMetadata === null) {
2437
+ diagnostics.push(
2438
+ diagnostic(
2439
+ workspace.packageJsonPath,
2440
+ packageName,
2441
+ 'invalid-adapter-metadata',
2442
+ `${packageName} must declare trails.adapters as an object keyed by exported subpath, with each value declaring a kebab-case target string.`,
2443
+ undefined,
2444
+ 'subpath'
2445
+ )
2446
+ );
2447
+ } else if (subpathMetadata !== undefined) {
2448
+ for (const subpathAdapter of subpathMetadata) {
2449
+ const subpathPackageName = `${packageName}/${subpathAdapter.exportKey.slice(2)}`;
2450
+ const targetEntry = targetById.get(subpathAdapter.target);
2451
+ const subpathPlacement =
2452
+ targetEntry?.ownerPackage === packageName
2453
+ ? 'subpath'
2454
+ : (placement ?? 'subpath');
2455
+ const subject = checkCandidate({
2456
+ exportKey: subpathAdapter.exportKey,
2457
+ key: subpathPackageName,
2458
+ metadata: subpathAdapter,
2459
+ packageName: subpathPackageName,
2460
+ placement: subpathPlacement,
2461
+ });
2462
+ if (subject) {
2463
+ subjects.push(subject);
2464
+ }
2465
+ }
2466
+ }
2467
+
2468
+ return { diagnostics, subjects };
2469
+ };
2470
+
2471
+ export const checkAdapters = (rootDir: string): AdapterCheckReport => {
2472
+ const catalog = deriveAdapterTargetCatalog(rootDir);
2473
+ const targetById = targetEntriesByTarget(catalog.targets);
2474
+ const diagnostics: AdapterCheckDiagnostic[] = [
2475
+ ...catalogDiagnostics(catalog),
2476
+ ];
2477
+ const subjects: AdapterCheckSubject[] = [];
2478
+
2479
+ for (const workspace of workspacePackages(rootDir)) {
2480
+ const result = checkAdapterPackage(workspace, targetById);
2481
+ diagnostics.push(...result.diagnostics);
2482
+ subjects.push(...result.subjects);
2483
+ }
2484
+
2485
+ const sortedSubjects = subjects.toSorted((left, right) =>
2486
+ left.key.localeCompare(right.key)
2487
+ );
2488
+
2489
+ return {
2490
+ diagnostics,
2491
+ facts: adapterFacts(catalog.targets, sortedSubjects),
2492
+ subjects: sortedSubjects,
2493
+ targets: catalog.targets,
2494
+ };
2495
+ };