@ontrails/adapter-kit 1.0.0-beta.32 → 1.0.0-beta.41

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 CHANGED
@@ -19,6 +19,13 @@ import type {
19
19
  DiagnosticBase,
20
20
  WorkspacePackage as CoreWorkspacePackage,
21
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';
22
29
 
23
30
  import { deriveAdapterTargetCatalog } from './catalog.js';
24
31
  import type {
@@ -114,9 +121,23 @@ interface AdapterMetadata {
114
121
  readonly target: string;
115
122
  }
116
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
+
117
136
  const adapterKitPackageName = '@ontrails/adapter-kit';
118
137
 
119
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;
120
141
 
121
142
  const isRecord = (value: unknown): value is Record<string, unknown> =>
122
143
  Boolean(value && typeof value === 'object' && !Array.isArray(value));
@@ -169,39 +190,46 @@ const exportTargetIsFile = (packageRoot: string, target: string): boolean => {
169
190
  }
170
191
  };
171
192
 
172
- const hasResolvableExport = (
193
+ const resolvableExportTarget = (
173
194
  workspace: WorkspacePackage,
174
195
  key: string
175
- ): boolean => {
196
+ ): string | undefined => {
176
197
  const { exports: exportsValue } = workspace.manifest;
177
198
  if (typeof exportsValue === 'string') {
178
- return (
179
- key === '.' && exportTargetIsFile(workspace.packageRoot, exportsValue)
180
- );
199
+ return key === '.' &&
200
+ exportTargetIsFile(workspace.packageRoot, exportsValue)
201
+ ? normalizeRealPath(resolve(workspace.packageRoot, exportsValue))
202
+ : undefined;
181
203
  }
182
204
 
183
205
  if (!isRecord(exportsValue)) {
184
- return false;
206
+ return undefined;
185
207
  }
186
208
 
187
209
  if (!Object.hasOwn(exportsValue, key)) {
188
210
  if (key !== '.') {
189
- return false;
211
+ return undefined;
190
212
  }
191
213
 
192
214
  const rootTarget = resolveExportTarget(exportsValue);
193
- return (
194
- rootTarget !== undefined &&
215
+ return rootTarget !== undefined &&
195
216
  exportTargetIsFile(workspace.packageRoot, rootTarget)
196
- );
217
+ ? normalizeRealPath(resolve(workspace.packageRoot, rootTarget))
218
+ : undefined;
197
219
  }
198
220
 
199
221
  const target = resolveExportTarget(exportsValue[key]);
200
- return (
201
- target !== undefined && exportTargetIsFile(workspace.packageRoot, target)
202
- );
222
+ return target !== undefined &&
223
+ exportTargetIsFile(workspace.packageRoot, target)
224
+ ? normalizeRealPath(resolve(workspace.packageRoot, target))
225
+ : undefined;
203
226
  };
204
227
 
228
+ const hasResolvableExport = (
229
+ workspace: WorkspacePackage,
230
+ key: string
231
+ ): boolean => resolvableExportTarget(workspace, key) !== undefined;
232
+
205
233
  const dependencyMap = (value: unknown): Readonly<Record<string, unknown>> =>
206
234
  isRecord(value) ? value : {};
207
235
 
@@ -232,6 +260,37 @@ const trailAdapterMetadata = (
232
260
  : null;
233
261
  };
234
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
+
235
294
  const placementForWorkspace = (
236
295
  workspacePath: string
237
296
  ): AdapterTargetPlacement | undefined =>
@@ -302,7 +361,10 @@ const adapterFacts = (
302
361
  placement: subject.placement,
303
362
  provenance: {
304
363
  packageJsonPath: subject.packageJsonPath,
305
- source: 'adapter-package-manifest',
364
+ source:
365
+ subject.placement === 'subpath'
366
+ ? 'owner-package-manifest'
367
+ : 'adapter-package-manifest',
306
368
  },
307
369
  target: subject.target,
308
370
  targetKey: subject.targetKey,
@@ -1105,48 +1167,147 @@ const dynamicImportNamespaceBindings = (
1105
1167
  const stringsMaskedCode = maskSource(source, { strings: true });
1106
1168
  const escapedSpecifier = escapeRegExp(specifier);
1107
1169
  const pattern = new RegExp(
1108
- `\\b(?:const|let|var)\\s+(?<local>[A-Za-z_$][\\w$]*)\\s*=\\s*(?:await\\s+)?import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)`,
1170
+ `\\bconst\\s+(?<local>[A-Za-z_$][\\w$]*)(?:\\s*:\\s*[^=;]+)?\\s*=\\s*await\\s+import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)`,
1109
1171
  'gu'
1110
1172
  );
1111
1173
 
1112
1174
  return [...code.matchAll(pattern)]
1113
1175
  .filter((match) =>
1114
- matchStartsWithAnyKeyword(stringsMaskedCode, match, [
1115
- 'const',
1116
- 'let',
1117
- 'var',
1118
- ])
1176
+ matchStartsWithAnyKeyword(stringsMaskedCode, match, ['const'])
1119
1177
  )
1120
1178
  .map((match) => match.groups?.['local'])
1121
1179
  .filter((local): local is string => local !== undefined);
1122
1180
  };
1123
1181
 
1124
- const dynamicImportNamedBinding = (
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 = (
1125
1234
  source: string,
1126
- specifier: string,
1127
- exportedName: string
1128
- ): string | undefined => {
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[] => {
1129
1285
  const code = maskSource(source, { strings: false });
1130
1286
  const stringsMaskedCode = maskSource(source, { strings: true });
1131
1287
  const escapedSpecifier = escapeRegExp(specifier);
1132
- const pattern = new RegExp(
1133
- `\\b(?:const|let|var)\\s*\\{(?<imports>[\\s\\S]*?)\\}\\s*=\\s*(?:await\\s+)?import\\s*\\(\\s*['"]${escapedSpecifier}['"]\\s*\\)`,
1134
- 'gu'
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'
1135
1293
  );
1136
1294
 
1137
- for (const match of code.matchAll(pattern)) {
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);
1138
1302
  if (
1139
- !matchStartsWithAnyKeyword(stringsMaskedCode, match, [
1140
- 'const',
1141
- 'let',
1142
- 'var',
1143
- ])
1303
+ closingBrace === undefined ||
1304
+ !assignmentPattern.test(code.slice(closingBrace + 1))
1144
1305
  ) {
1145
1306
  continue;
1146
1307
  }
1147
1308
 
1148
- const namedImports = match.groups?.['imports'] ?? '';
1149
- for (const item of namedImports.split(',')) {
1309
+ const namedImports = code.slice(openingBrace + 1, closingBrace);
1310
+ for (const item of splitDestructuringBindings(namedImports)) {
1150
1311
  const specifierText = item.trim();
1151
1312
  if (specifierText.length === 0 || specifierText.startsWith('...')) {
1152
1313
  continue;
@@ -1156,15 +1317,27 @@ const dynamicImportNamedBinding = (
1156
1317
  /^(?<imported>[A-Za-z_$][\w$]*)(?:\s*:\s*(?<local>[A-Za-z_$][\w$]*))?(?:\s*=.*)?$/u.exec(
1157
1318
  specifierText
1158
1319
  )?.groups;
1159
- if (imported?.['imported'] === exportedName) {
1160
- return imported['local'] ?? imported['imported'];
1320
+ if (imported?.['imported']) {
1321
+ bindings.push({
1322
+ imported: imported['imported'],
1323
+ local: imported['local'] ?? imported['imported'],
1324
+ });
1161
1325
  }
1162
1326
  }
1163
1327
  }
1164
1328
 
1165
- return undefined;
1329
+ return bindings;
1166
1330
  };
1167
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
+
1168
1341
  interface LocalValueExport {
1169
1342
  readonly identifier: string;
1170
1343
  readonly sourcePath: string;
@@ -1495,90 +1668,218 @@ const previousNonWhitespace = (source: string, index: number): string => {
1495
1668
 
1496
1669
  const containsCall = (source: string, identifier: string): boolean => {
1497
1670
  const escapedIdentifier = escapeRegExp(identifier);
1498
- const callPattern = new RegExp(`\\b${escapedIdentifier}\\s*\\(`, 'gu');
1671
+ const callPattern = new RegExp(`${escapedIdentifier}\\s*\\(`, 'gu');
1499
1672
  for (const match of source.matchAll(callPattern)) {
1500
- if (previousNonWhitespace(source, match.index ?? 0) !== '.') {
1673
+ const index = match.index ?? 0;
1674
+ if (
1675
+ !isIdentifierChar(source[index - 1]) &&
1676
+ previousNonWhitespace(source, index) !== '.'
1677
+ ) {
1501
1678
  return true;
1502
1679
  }
1503
1680
  }
1504
1681
  return false;
1505
1682
  };
1506
1683
 
1507
- const splitTopLevelArguments = (source: string): readonly string[] => {
1508
- const args: string[] = [];
1509
- let start = 0;
1510
- let parenDepth = 0;
1511
- let braceDepth = 0;
1512
- let bracketDepth = 0;
1684
+ const promiseMethodNames = new Set(['then', 'catch', 'finally']);
1513
1685
 
1514
- for (let index = 0; index < source.length; index += 1) {
1515
- const char = source[index];
1516
- if (char === '(') {
1517
- parenDepth += 1;
1518
- continue;
1519
- }
1520
- if (char === ')') {
1521
- parenDepth = Math.max(0, parenDepth - 1);
1522
- continue;
1523
- }
1524
- if (char === '{') {
1525
- braceDepth += 1;
1526
- continue;
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);
1527
1723
  }
1528
- if (char === '}') {
1529
- braceDepth = Math.max(0, braceDepth - 1);
1530
- continue;
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
+ }
1531
1737
  }
1532
- if (char === '[') {
1533
- bracketDepth += 1;
1534
- continue;
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);
1535
1744
  }
1536
- if (char === ']') {
1537
- bracketDepth = Math.max(0, bracketDepth - 1);
1538
- continue;
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;
1539
1815
  }
1816
+ const callee = node['callee'] as AstNode | undefined;
1817
+ const member = callee && namespacedCalleeName(callee);
1540
1818
  if (
1541
- char === ',' &&
1542
- parenDepth === 0 &&
1543
- braceDepth === 0 &&
1544
- bracketDepth === 0
1819
+ member?.receiver === binding &&
1820
+ !promiseMethodNames.has(member.property) &&
1821
+ !isShadowed(binding, scopes)
1545
1822
  ) {
1546
- args.push(source.slice(start, index).trim());
1547
- start = index + 1;
1823
+ called = true;
1548
1824
  }
1549
- }
1550
-
1551
- const trailing = source.slice(start).trim();
1552
- if (trailing || args.length > 0) {
1553
- args.push(trailing);
1554
- }
1555
- return args;
1825
+ });
1826
+ return called;
1556
1827
  };
1557
1828
 
1558
1829
  const runnerCallArguments = (
1559
1830
  source: string,
1560
- runner: string
1561
- ): readonly (readonly string[])[] => {
1562
- const code = maskSource(source, { strings: true });
1563
- const escapedRunner = escapeRegExp(runner);
1564
- const runnerPattern = new RegExp(`\\b${escapedRunner}\\s*\\(`, 'gu');
1565
- const calls: (readonly 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
+ }));
1566
1840
 
1567
- for (const match of code.matchAll(runnerPattern)) {
1568
- const matchIndex = match.index ?? 0;
1569
- if (previousNonWhitespace(code, matchIndex) === '.') {
1570
- continue;
1571
- }
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
+ );
1572
1850
 
1573
- const openIndex = code.indexOf('(', matchIndex);
1574
- const closeIndex = findClosingParen(code, openIndex);
1575
- if (closeIndex === -1) {
1576
- continue;
1577
- }
1578
- calls.push(splitTopLevelArguments(code.slice(openIndex + 1, closeIndex)));
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;
1579
1869
  }
1580
1870
 
1581
- return calls;
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
+ );
1582
1883
  };
1583
1884
 
1584
1885
  const isSentinelAdapterArgument = (argument: string): boolean =>
@@ -1586,33 +1887,42 @@ const isSentinelAdapterArgument = (argument: string): boolean =>
1586
1887
 
1587
1888
  const runnerInvokesCasesFactory = (
1588
1889
  source: string,
1890
+ ast: AstNode,
1589
1891
  runner: string,
1590
1892
  casesFactory: string
1591
1893
  ): boolean =>
1592
- runnerCallArguments(source, runner).some((args) => {
1894
+ runnerCallArguments(source, runner, ast).some((call) => {
1895
+ const args = call.arguments;
1593
1896
  const adapterArgument = args[0]?.trim();
1594
1897
  return (
1595
1898
  adapterArgument !== undefined &&
1596
1899
  adapterArgument.length > 0 &&
1597
1900
  !isSentinelAdapterArgument(adapterArgument) &&
1598
- !containsCall(adapterArgument, casesFactory) &&
1599
- args.slice(1).some((argument) => containsCall(argument, casesFactory))
1901
+ !argumentContainsProvenCall(call.argumentNodes[0], ast, casesFactory) &&
1902
+ call.argumentNodes
1903
+ .slice(1)
1904
+ .some((argument) =>
1905
+ argumentContainsProvenCall(argument, ast, casesFactory)
1906
+ )
1600
1907
  );
1601
1908
  });
1602
1909
 
1603
1910
  const runnerInvokedWithAdapterArgument = (
1604
1911
  source: string,
1912
+ ast: AstNode,
1605
1913
  runner: string,
1606
1914
  casesFactories: readonly string[] = []
1607
1915
  ): boolean =>
1608
- runnerCallArguments(source, runner).some((args) => {
1916
+ runnerCallArguments(source, runner, ast).some((call) => {
1917
+ const args = call.arguments;
1609
1918
  const adapterArgument = args[0]?.trim();
1610
1919
  return (
1611
1920
  adapterArgument !== undefined &&
1612
1921
  adapterArgument.length > 0 &&
1613
1922
  !isSentinelAdapterArgument(adapterArgument) &&
1614
1923
  casesFactories.every(
1615
- (casesFactory) => !containsCall(adapterArgument, casesFactory)
1924
+ (casesFactory) =>
1925
+ !argumentContainsProvenCall(call.argumentNodes[0], ast, casesFactory)
1616
1926
  )
1617
1927
  );
1618
1928
  });
@@ -1689,12 +1999,15 @@ const ownerRunnerDefaultsCasesFactory = (
1689
1999
 
1690
2000
  const runnerBindingProvesConformance = (
1691
2001
  source: string,
2002
+ ast: AstNode,
1692
2003
  targetEntry: AdapterTargetCatalogEntry,
1693
2004
  runnerBinding: string,
1694
2005
  casesFactoryBindings: readonly string[]
1695
2006
  ): boolean => {
1696
2007
  for (const casesFactoryBinding of casesFactoryBindings) {
1697
- if (runnerInvokesCasesFactory(source, runnerBinding, casesFactoryBinding)) {
2008
+ if (
2009
+ runnerInvokesCasesFactory(source, ast, runnerBinding, casesFactoryBinding)
2010
+ ) {
1698
2011
  return true;
1699
2012
  }
1700
2013
  }
@@ -1703,6 +2016,7 @@ const runnerBindingProvesConformance = (
1703
2016
  ownerRunnerDefaultsCasesFactory(targetEntry) &&
1704
2017
  runnerInvokedWithAdapterArgument(
1705
2018
  source,
2019
+ ast,
1706
2020
  runnerBinding,
1707
2021
  casesFactoryBindings
1708
2022
  )
@@ -1724,72 +2038,63 @@ const provesConformance = (
1724
2038
  return false;
1725
2039
  }
1726
2040
 
2041
+ const ast = parse('adapter-conformance.ts', source);
2042
+ if (!ast) {
2043
+ return false;
2044
+ }
2045
+
1727
2046
  if (!conformance) {
1728
- return true;
2047
+ return importedRuntimeBindingIsCalled(source, testingImport, ast);
1729
2048
  }
1730
2049
 
1731
- const runnerBindings = namedImportBindings(
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(
1732
2057
  source,
1733
2058
  testingImport,
1734
2059
  conformance.runner
1735
2060
  );
1736
- const casesFactoryBindings = namedImportBindings(
2061
+ for (const dynamicRunnerBinding of dynamicRunnerBindings) {
2062
+ runnerBindings.add(dynamicRunnerBinding);
2063
+ }
2064
+ const dynamicCasesFactoryBindings = dynamicImportNamedLocals(
1737
2065
  source,
1738
2066
  testingImport,
1739
2067
  conformance.casesFactory
1740
2068
  );
1741
- if (runnerBindings.length === 0) {
1742
- const dynamicRunnerBinding = dynamicImportNamedBinding(
1743
- source,
1744
- testingImport,
1745
- conformance.runner
1746
- );
1747
- const dynamicCasesFactoryBinding = dynamicImportNamedBinding(
1748
- source,
1749
- testingImport,
1750
- conformance.casesFactory
1751
- );
1752
- const dynamicCasesFactoryBindings = dynamicCasesFactoryBinding
1753
- ? [dynamicCasesFactoryBinding]
1754
- : [];
1755
- if (
1756
- dynamicRunnerBinding &&
1757
- runnerBindingProvesConformance(
1758
- source,
1759
- targetEntry,
1760
- dynamicRunnerBinding,
1761
- dynamicCasesFactoryBindings
1762
- )
1763
- ) {
1764
- return true;
1765
- }
1766
-
1767
- for (const namespaceBinding of dynamicImportNamespaceBindings(
1768
- source,
1769
- testingImport
1770
- )) {
1771
- const namespaceRunnerBinding = `${namespaceBinding}.${conformance.runner}`;
1772
- const namespaceCasesFactoryBinding = `${namespaceBinding}.${conformance.casesFactory}`;
1773
- if (
1774
- runnerBindingProvesConformance(
1775
- source,
1776
- targetEntry,
1777
- namespaceRunnerBinding,
1778
- [namespaceCasesFactoryBinding]
1779
- )
1780
- ) {
1781
- return true;
1782
- }
1783
- }
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
+ }
1784
2079
 
1785
- return false;
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}`);
1786
2088
  }
1787
- return runnerBindings.some((runnerBinding) =>
2089
+
2090
+ const allCasesFactoryBindings = [...casesFactoryBindings];
2091
+ return [...runnerBindings].some((runnerBinding) =>
1788
2092
  runnerBindingProvesConformance(
1789
2093
  source,
2094
+ ast,
1790
2095
  targetEntry,
1791
2096
  runnerBinding,
1792
- casesFactoryBindings
2097
+ allCasesFactoryBindings
1793
2098
  )
1794
2099
  );
1795
2100
  };
@@ -1923,126 +2228,244 @@ const assertToolingBoundary = (
1923
2228
  }
1924
2229
  };
1925
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
+
1926
2264
  const checkAdapterPackage = (
1927
2265
  workspace: WorkspacePackage,
1928
2266
  targetById: ReadonlyMap<string, AdapterTargetCatalogEntry>
1929
2267
  ): {
1930
2268
  readonly diagnostics: readonly AdapterCheckDiagnostic[];
1931
- readonly subject?: AdapterCheckSubject | undefined;
2269
+ readonly subjects: readonly AdapterCheckSubject[];
1932
2270
  } => {
1933
2271
  const packageName = workspace.manifest.name as string;
1934
2272
  const diagnostics: AdapterCheckDiagnostic[] = [];
1935
2273
  const placement = placementForWorkspace(workspace.workspacePath);
1936
2274
  const metadata = trailAdapterMetadata(workspace.manifest);
2275
+ const subpathMetadata = trailSubpathAdapterMetadata(workspace.manifest);
1937
2276
 
1938
- if (!placement || metadata === undefined) {
1939
- return { diagnostics: [] };
2277
+ if ((!placement || metadata === undefined) && subpathMetadata === undefined) {
2278
+ return { diagnostics: [], subjects: [] };
1940
2279
  }
1941
2280
 
1942
2281
  assertPackageExports(workspace, diagnostics);
1943
2282
  const sourceFiles = collectSourceFiles(join(workspace.packageRoot, 'src'));
1944
2283
  assertToolingBoundary(workspace, sourceFiles, diagnostics);
1945
2284
 
1946
- if (metadata === null) {
1947
- return {
1948
- diagnostics: [
1949
- ...diagnostics,
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(
1950
2294
  diagnostic(
1951
2295
  workspace.packageJsonPath,
1952
- packageName,
1953
- 'invalid-adapter-metadata',
1954
- `${packageName} must declare trails.adapter as an object with a kebab-case target string.`,
1955
- undefined,
1956
- placement
1957
- ),
1958
- ],
1959
- };
1960
- }
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
+ }
1961
2304
 
1962
- const targetEntry = targetById.get(metadata.target);
1963
- if (!targetEntry) {
1964
- return {
1965
- diagnostics: [
1966
- ...diagnostics,
2305
+ const targetEntry = targetById.get(candidate.metadata.target);
2306
+ if (!targetEntry) {
2307
+ diagnostics.push(
1967
2308
  diagnostic(
1968
2309
  workspace.packageJsonPath,
1969
- packageName,
2310
+ candidate.packageName,
1970
2311
  'unknown-adapter-target',
1971
- `${packageName} declares unknown adapter target "${metadata.target}".`,
1972
- metadata.target,
1973
- placement
1974
- ),
1975
- ],
1976
- };
1977
- }
2312
+ `${candidate.packageName} declares unknown adapter target "${candidate.metadata.target}".`,
2313
+ candidate.metadata.target,
2314
+ candidate.placement
2315
+ )
2316
+ );
2317
+ return undefined;
2318
+ }
1978
2319
 
1979
- if (!targetEntry.placements.includes(placement)) {
1980
- diagnostics.push(
1981
- diagnostic(
1982
- workspace.packageJsonPath,
1983
- packageName,
1984
- 'unsupported-placement',
1985
- `${targetEntry.ownerPackage}:${targetEntry.target} does not support ${placement} adapter placement.`,
1986
- targetEntry.target,
1987
- placement
1988
- )
1989
- );
1990
- }
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
+ }
1991
2336
 
1992
- if (placement === 'extracted') {
1993
- assertDependencyDirection(workspace, targetEntry, diagnostics);
1994
- }
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
+ }
1995
2349
 
1996
- const { conformance, testingImport } = targetEntry;
1997
- const conformanceTestPaths = testingImport
1998
- ? pathsProvingConformance(sourceFiles.filter(isTestFile), targetEntry)
1999
- : [];
2350
+ if (candidate.placement === 'extracted') {
2351
+ assertDependencyDirection(workspace, targetEntry, diagnostics);
2352
+ }
2000
2353
 
2001
- if (!testingImport) {
2002
- diagnostics.push(
2003
- diagnostic(
2004
- workspace.packageJsonPath,
2005
- packageName,
2006
- 'missing-owner-conformance',
2007
- `${targetEntry.ownerPackage}:${targetEntry.target} must declare testingImport before adapters can prove conformance.`,
2008
- targetEntry.target,
2009
- placement
2010
- )
2354
+ const { conformance, testingImport } = targetEntry;
2355
+ const candidateSourceFiles = sourceFilesForCandidate(
2356
+ workspace,
2357
+ candidate,
2358
+ sourceFiles
2011
2359
  );
2012
- } else if (conformanceTestPaths.length === 0) {
2013
- const conformanceHint = conformance
2014
- ? ` and call ${conformance.runner}(adapter, ${conformance.casesFactory}(...))`
2015
- : '';
2016
- diagnostics.push(
2017
- diagnostic(
2018
- workspace.packageJsonPath,
2019
- packageName,
2020
- 'missing-conformance',
2021
- `${packageName} must import ${testingImport} from a conformance test${conformanceHint}.`,
2022
- targetEntry.target,
2023
- placement
2024
- )
2025
- );
2026
- }
2360
+ const conformanceTestPaths = testingImport
2361
+ ? pathsProvingConformance(
2362
+ candidateSourceFiles.filter(isTestFile),
2363
+ targetEntry
2364
+ )
2365
+ : [];
2027
2366
 
2028
- return {
2029
- diagnostics,
2030
- subject: {
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 {
2031
2395
  conformanceTestPaths,
2032
- key: packageName,
2396
+ key: candidate.key,
2033
2397
  ownerPackage: targetEntry.ownerPackage,
2034
2398
  packageJsonPath: workspace.packageJsonPath,
2035
- packageName,
2399
+ packageName: candidate.packageName,
2036
2400
  packageRoot: workspace.packageRoot,
2037
- placement,
2401
+ placement: candidate.placement,
2038
2402
  target: targetEntry.target,
2039
2403
  targetKey: targetEntry.key,
2040
2404
  ...(conformance?.adapterType
2041
2405
  ? { adapterType: conformance.adapterType }
2042
2406
  : {}),
2043
2407
  ...(testingImport ? { testingImport } : {}),
2044
- },
2408
+ };
2045
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 };
2046
2469
  };
2047
2470
 
2048
2471
  export const checkAdapters = (rootDir: string): AdapterCheckReport => {
@@ -2056,9 +2479,7 @@ export const checkAdapters = (rootDir: string): AdapterCheckReport => {
2056
2479
  for (const workspace of workspacePackages(rootDir)) {
2057
2480
  const result = checkAdapterPackage(workspace, targetById);
2058
2481
  diagnostics.push(...result.diagnostics);
2059
- if (result.subject) {
2060
- subjects.push(result.subject);
2061
- }
2482
+ subjects.push(...result.subjects);
2062
2483
  }
2063
2484
 
2064
2485
  const sortedSubjects = subjects.toSorted((left, right) =>