@ontrails/trails 1.0.0-beta.17 → 1.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +139 -0
  2. package/README.md +7 -10
  3. package/package.json +13 -12
  4. package/src/app.ts +14 -4
  5. package/src/cli.ts +16 -0
  6. package/src/lifecycle-source-io.ts +33 -0
  7. package/src/project-writes.ts +62 -5
  8. package/src/retired-topo-command.ts +36 -0
  9. package/src/run-adapter-check.ts +76 -0
  10. package/src/run-collision.ts +1 -0
  11. package/src/trails/adapter-check.ts +244 -0
  12. package/src/trails/add-surface.ts +18 -18
  13. package/src/trails/add-trail.ts +3 -2
  14. package/src/trails/add-verify.ts +30 -6
  15. package/src/trails/{topo-compile.ts → compile.ts} +16 -8
  16. package/src/trails/completions-complete.ts +1 -1
  17. package/src/trails/create-adapter.ts +1084 -0
  18. package/src/trails/create-scaffold.ts +243 -29
  19. package/src/trails/create.ts +118 -17
  20. package/src/trails/deprecate.ts +59 -0
  21. package/src/trails/dev-clean.ts +2 -2
  22. package/src/trails/dev-reset.ts +2 -2
  23. package/src/trails/dev-stats.ts +1 -1
  24. package/src/trails/doctor.ts +56 -0
  25. package/src/trails/draft-promote.ts +1 -0
  26. package/src/trails/guide.ts +2 -2
  27. package/src/trails/revise.ts +53 -0
  28. package/src/trails/run-example.ts +12 -7
  29. package/src/trails/run-examples.ts +3 -3
  30. package/src/trails/run.ts +7 -4
  31. package/src/trails/survey.ts +332 -25
  32. package/src/trails/topo-history.ts +1 -1
  33. package/src/trails/topo-output-schemas.ts +30 -1
  34. package/src/trails/topo-pin.ts +3 -2
  35. package/src/trails/topo-read-support.ts +49 -8
  36. package/src/trails/topo-reports.ts +39 -22
  37. package/src/trails/topo-store-support.ts +62 -16
  38. package/src/trails/topo-support.ts +1 -1
  39. package/src/trails/topo-unpin.ts +2 -2
  40. package/src/trails/topo.ts +2 -2
  41. package/src/trails/{topo-verify.ts → validate.ts} +7 -7
  42. package/src/trails/version-lifecycle-support.ts +945 -0
  43. package/src/trails/warden-guide.ts +8 -0
  44. package/src/trails/warden.ts +18 -2
  45. package/src/versions.ts +4 -1
@@ -0,0 +1,1084 @@
1
+ /**
2
+ * `create.adapter` trail -- Scaffold an adapter authoring target.
3
+ */
4
+
5
+ import {
6
+ adapterTargetPlacements,
7
+ checkAdapters,
8
+ deriveAdapterTargetCatalog,
9
+ } from '@ontrails/adapter-kit';
10
+ import type {
11
+ AdapterCheckDiagnostic,
12
+ AdapterTargetCatalogEntry,
13
+ AdapterTargetConformanceManifest,
14
+ AdapterTargetPlacement,
15
+ } from '@ontrails/adapter-kit';
16
+ import { Result, trail, ValidationError } from '@ontrails/core';
17
+ import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
18
+ import { dirname, join, relative, resolve } from 'node:path';
19
+ import { z } from 'zod';
20
+
21
+ import {
22
+ applyProjectOperations,
23
+ planProjectOperations,
24
+ resolveProjectPath,
25
+ } from '../project-writes.js';
26
+ import type {
27
+ PlannedProjectOperation,
28
+ ProjectWriteOperation,
29
+ } from '../project-writes.js';
30
+ import { trailsPackageVersion } from '../versions.js';
31
+ import { resolveTrailRootDir } from './root-dir.js';
32
+
33
+ const adapterNamePattern = /^[a-z][a-z0-9-]*$/u;
34
+ const adapterNameMessage =
35
+ 'Adapter name must be kebab-case, start with a lowercase letter, and contain only lowercase letters, digits, or "-".';
36
+ const packageNamePattern =
37
+ /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
38
+ const packageNameMessage =
39
+ 'Package name must be a valid lowercase npm package name, optionally scoped.';
40
+
41
+ type CreateAdapterPlacement = AdapterTargetPlacement;
42
+
43
+ const createAdapterPlacements = ['extracted'] as const;
44
+
45
+ interface CreateAdapterInput {
46
+ readonly dryRun: boolean;
47
+ readonly name: string;
48
+ readonly packageName?: string | undefined;
49
+ readonly placement: CreateAdapterPlacement;
50
+ readonly rootDir?: string | undefined;
51
+ readonly target: string;
52
+ }
53
+
54
+ interface CreateAdapterResult {
55
+ readonly adapterImport: string;
56
+ readonly created: readonly string[];
57
+ readonly diagnostics: readonly AdapterCheckDiagnostic[];
58
+ readonly dryRun: boolean;
59
+ readonly packageName: string;
60
+ readonly placement: CreateAdapterPlacement;
61
+ readonly plannedOperations: readonly PlannedProjectOperation[];
62
+ readonly targetKey: string;
63
+ }
64
+
65
+ interface AdapterOperationPlan {
66
+ readonly adapterImport: string;
67
+ readonly packageName: string;
68
+ readonly operations: readonly ProjectWriteOperation[];
69
+ readonly targetKey: string;
70
+ }
71
+
72
+ interface RootManifest {
73
+ readonly workspaces?: unknown;
74
+ }
75
+
76
+ interface WorkspacePackageManifest {
77
+ readonly exports?: unknown;
78
+ readonly name?: unknown;
79
+ }
80
+
81
+ interface WorkspacePackageName {
82
+ readonly name: string;
83
+ readonly workspacePath: string;
84
+ }
85
+
86
+ interface LocalNamedReexport {
87
+ readonly local: string;
88
+ readonly specifier: string;
89
+ readonly typeOnly: boolean;
90
+ }
91
+
92
+ interface LocalNamedImport {
93
+ readonly imported: string;
94
+ readonly specifier: string;
95
+ readonly typeOnly: boolean;
96
+ }
97
+
98
+ const literal = (value: string): string => JSON.stringify(value);
99
+
100
+ const writeOperation = (
101
+ path: string,
102
+ content: string
103
+ ): ProjectWriteOperation => ({
104
+ content,
105
+ kind: 'write',
106
+ path,
107
+ });
108
+
109
+ const formatJson = (value: unknown): string =>
110
+ `${JSON.stringify(value, null, 2)}\n`;
111
+
112
+ const defaultExtractedPackageName = (name: string): string =>
113
+ `@ontrails/${name}`;
114
+
115
+ const fail = (message: string): Result<never, ValidationError> =>
116
+ Result.err(new ValidationError(message));
117
+
118
+ const readJson = <T>(path: string): T | undefined => {
119
+ try {
120
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ };
125
+
126
+ const workspacePatternsFromManifest = (
127
+ manifest: RootManifest | undefined
128
+ ): readonly string[] => {
129
+ const { workspaces } = manifest ?? {};
130
+ if (Array.isArray(workspaces)) {
131
+ return workspaces.filter(
132
+ (pattern): pattern is string => typeof pattern === 'string'
133
+ );
134
+ }
135
+
136
+ const packages =
137
+ workspaces && typeof workspaces === 'object' && !Array.isArray(workspaces)
138
+ ? (workspaces as Record<string, unknown>)['packages']
139
+ : undefined;
140
+ return Array.isArray(packages)
141
+ ? packages.filter(
142
+ (pattern): pattern is string => typeof pattern === 'string'
143
+ )
144
+ : [];
145
+ };
146
+
147
+ const normalizeWorkspacePattern = (pattern: string): string =>
148
+ pattern.replace(/^\.\//u, '').replace(/\/+$/u, '');
149
+
150
+ const workspacePatternCoversPath = (
151
+ pattern: string,
152
+ workspacePath: string
153
+ ): boolean => {
154
+ const normalized = normalizeWorkspacePattern(pattern);
155
+ if (normalized.endsWith('/*')) {
156
+ const prefix = normalized.slice(0, -2);
157
+ const rest = workspacePath.slice(prefix.length + 1);
158
+ return (
159
+ workspacePath.startsWith(`${prefix}/`) &&
160
+ rest.length > 0 &&
161
+ !rest.includes('/')
162
+ );
163
+ }
164
+
165
+ return normalized === workspacePath;
166
+ };
167
+
168
+ const rootWorkspaceIncludesPath = (
169
+ rootDir: string,
170
+ workspacePath: string
171
+ ): boolean => {
172
+ const rootManifest = readJson<RootManifest>(join(rootDir, 'package.json'));
173
+ return workspacePatternsFromManifest(rootManifest).some((pattern) =>
174
+ workspacePatternCoversPath(pattern, workspacePath)
175
+ );
176
+ };
177
+
178
+ const workspaceDirsForPattern = (
179
+ rootDir: string,
180
+ pattern: string
181
+ ): readonly string[] => {
182
+ if (!pattern.endsWith('/*')) {
183
+ const workspaceDir = join(rootDir, pattern);
184
+ return existsSync(workspaceDir) ? [workspaceDir] : [];
185
+ }
186
+
187
+ const groupDir = join(rootDir, pattern.slice(0, -2));
188
+ if (!existsSync(groupDir)) {
189
+ return [];
190
+ }
191
+
192
+ return readdirSync(groupDir, { withFileTypes: true })
193
+ .filter((entry) => entry.isDirectory())
194
+ .map((entry) => join(groupDir, entry.name))
195
+ .toSorted();
196
+ };
197
+
198
+ const findWorkspacePackageName = (
199
+ rootDir: string,
200
+ packageName: string
201
+ ): WorkspacePackageName | undefined => {
202
+ const rootManifest = readJson<RootManifest>(join(rootDir, 'package.json'));
203
+ for (const pattern of workspacePatternsFromManifest(rootManifest)) {
204
+ for (const workspaceDir of workspaceDirsForPattern(rootDir, pattern)) {
205
+ const manifest = readJson<WorkspacePackageManifest>(
206
+ join(workspaceDir, 'package.json')
207
+ );
208
+ if (manifest?.name === packageName) {
209
+ return {
210
+ name: packageName,
211
+ workspacePath: relative(rootDir, workspaceDir),
212
+ };
213
+ }
214
+ }
215
+ }
216
+
217
+ return undefined;
218
+ };
219
+
220
+ const resolvePhysicalRootDir = (
221
+ rootDir: string
222
+ ): Result<string, ValidationError> => {
223
+ try {
224
+ return Result.ok(realpathSync(resolve(rootDir)));
225
+ } catch {
226
+ return fail(
227
+ `Workspace root "${rootDir}" does not exist or cannot be read.`
228
+ );
229
+ }
230
+ };
231
+
232
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
233
+ typeof value === 'object' && value !== null && !Array.isArray(value);
234
+
235
+ const runtimeExportTarget = (value: unknown, depth = 0): string | undefined => {
236
+ if (typeof value === 'string') {
237
+ return value;
238
+ }
239
+ if (!isRecord(value) || depth > 8) {
240
+ return undefined;
241
+ }
242
+
243
+ for (const condition of ['bun', 'import', 'default', 'require'] as const) {
244
+ const candidate = runtimeExportTarget(value[condition], depth + 1);
245
+ if (candidate) {
246
+ return candidate;
247
+ }
248
+ }
249
+
250
+ return undefined;
251
+ };
252
+
253
+ const packageRootExportTarget = (
254
+ target: AdapterTargetCatalogEntry
255
+ ): string | undefined => {
256
+ const manifest = readJson<WorkspacePackageManifest>(target.packageJsonPath);
257
+ let exportTarget: string | undefined;
258
+ if (typeof manifest?.exports === 'string') {
259
+ exportTarget = manifest.exports;
260
+ } else if (isRecord(manifest?.exports)) {
261
+ const rootExport = Object.hasOwn(manifest.exports, '.')
262
+ ? manifest.exports['.']
263
+ : manifest.exports;
264
+ exportTarget = runtimeExportTarget(rootExport);
265
+ }
266
+
267
+ if (!exportTarget || exportTarget.startsWith('..')) {
268
+ return undefined;
269
+ }
270
+
271
+ return resolve(target.packageRoot, exportTarget);
272
+ };
273
+
274
+ const maskSource = (source: string, options: { strings: boolean }): string => {
275
+ const output = [...source];
276
+ let index = 0;
277
+
278
+ const maskRange = (start: number, end: number): void => {
279
+ for (let cursor = start; cursor < end; cursor += 1) {
280
+ if (output[cursor] !== '\n') {
281
+ output[cursor] = ' ';
282
+ }
283
+ }
284
+ };
285
+
286
+ const skipQuoted = (quote: '"' | "'" | '`'): void => {
287
+ const start = index;
288
+ index += 1;
289
+ while (index < source.length) {
290
+ if (source[index] === '\\') {
291
+ index += 2;
292
+ continue;
293
+ }
294
+ if (source[index] === quote) {
295
+ index += 1;
296
+ break;
297
+ }
298
+ index += 1;
299
+ }
300
+ if (options.strings) {
301
+ maskRange(start, index);
302
+ }
303
+ };
304
+
305
+ while (index < source.length) {
306
+ if (source.startsWith('//', index)) {
307
+ const end = source.indexOf('\n', index + 2);
308
+ const stop = end === -1 ? source.length : end;
309
+ maskRange(index, stop);
310
+ index = stop;
311
+ continue;
312
+ }
313
+ if (source.startsWith('/*', index)) {
314
+ const end = source.indexOf('*/', index + 2);
315
+ const stop = end === -1 ? source.length : end + 2;
316
+ maskRange(index, stop);
317
+ index = stop;
318
+ continue;
319
+ }
320
+ const char = source[index];
321
+ if (char === '"' || char === "'" || char === '`') {
322
+ skipQuoted(char);
323
+ continue;
324
+ }
325
+ index += 1;
326
+ }
327
+
328
+ return output.join('');
329
+ };
330
+
331
+ const sameFileExportListLocalForIdentifier = (
332
+ source: string,
333
+ identifier: string,
334
+ expected: 'type' | 'value'
335
+ ): string | undefined => {
336
+ const exportCode = maskSource(source, { strings: false });
337
+ const stringsMaskedCode = maskSource(source, { strings: true });
338
+ const exportListPattern =
339
+ /\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}(?<from>\s+from\s+['"][^'"]+['"])?/gu;
340
+ for (const match of exportCode.matchAll(exportListPattern)) {
341
+ if (!stringsMaskedCode.startsWith('export', match.index ?? 0)) {
342
+ continue;
343
+ }
344
+
345
+ if (match.groups?.['from']) {
346
+ continue;
347
+ }
348
+
349
+ const namedExports = match.groups?.['exports'] ?? '';
350
+ for (const item of namedExports.split(',')) {
351
+ const trimmedItem = item.trim();
352
+ const specifierTypeOnly =
353
+ Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
354
+ const specifierText = trimmedItem.replace(/^type\s+/u, '');
355
+ const exported =
356
+ /^(?<local>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<name>[A-Za-z_$][\w$]*))?$/u.exec(
357
+ specifierText
358
+ )?.groups;
359
+ if (
360
+ !exported?.['local'] ||
361
+ (exported['name'] ?? exported['local']) !== identifier
362
+ ) {
363
+ continue;
364
+ }
365
+ if (expected === 'type') {
366
+ return exported['local'];
367
+ }
368
+ if (!specifierTypeOnly) {
369
+ return exported['local'];
370
+ }
371
+ }
372
+ }
373
+
374
+ return undefined;
375
+ };
376
+
377
+ const declaresTypeBinding = (source: string, identifier: string): boolean => {
378
+ const code = maskSource(source, { strings: true });
379
+ const escapedIdentifier = identifier.replaceAll(
380
+ /[.*+?^${}()|[\]\\]/gu,
381
+ '\\$&'
382
+ );
383
+ return new RegExp(
384
+ `(?:^|[;\\n\\r])\\s*(?:export\\s+)?(?:declare\\s+)?(?:interface|type)\\s+${escapedIdentifier}\\b`,
385
+ 'u'
386
+ ).test(code);
387
+ };
388
+
389
+ const declaresValueBinding = (source: string, identifier: string): boolean => {
390
+ const code = maskSource(source, { strings: true });
391
+ const escapedIdentifier = identifier.replaceAll(
392
+ /[.*+?^${}()|[\]\\]/gu,
393
+ '\\$&'
394
+ );
395
+ return new RegExp(
396
+ `(?:^|[;\\n\\r])\\s*(?:export\\s+)?(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
397
+ 'u'
398
+ ).test(code);
399
+ };
400
+
401
+ const sourceExportsIdentifier = (
402
+ source: string,
403
+ identifier: string,
404
+ expected: 'type' | 'value'
405
+ ): boolean => {
406
+ const code = maskSource(source, { strings: true });
407
+ const escapedIdentifier = identifier.replaceAll(
408
+ /[.*+?^${}()|[\]\\]/gu,
409
+ '\\$&'
410
+ );
411
+ const valueDeclarationPattern = new RegExp(
412
+ `\\bexport\\s+(?:(?:async\\s+)?function|const|let|var|class|enum)\\s+${escapedIdentifier}\\b`,
413
+ 'u'
414
+ );
415
+ if (expected === 'value' && valueDeclarationPattern.test(code)) {
416
+ return true;
417
+ }
418
+
419
+ const typeDeclarationPattern = new RegExp(
420
+ `\\bexport\\s+(?:declare\\s+)?(?:interface|type)\\s+${escapedIdentifier}\\b`,
421
+ 'u'
422
+ );
423
+ const sameFileLocal = sameFileExportListLocalForIdentifier(
424
+ source,
425
+ identifier,
426
+ expected
427
+ );
428
+ return (
429
+ (expected === 'type' &&
430
+ (typeDeclarationPattern.test(code) ||
431
+ (sameFileLocal !== undefined &&
432
+ declaresTypeBinding(source, sameFileLocal)))) ||
433
+ (expected === 'value' &&
434
+ sameFileLocal !== undefined &&
435
+ declaresValueBinding(source, sameFileLocal))
436
+ );
437
+ };
438
+
439
+ const localNamedImports = (
440
+ source: string,
441
+ identifier: string
442
+ ): readonly LocalNamedImport[] => {
443
+ const code = maskSource(source, { strings: false });
444
+ const stringsMaskedCode = maskSource(source, { strings: true });
445
+ const imports: LocalNamedImport[] = [];
446
+ const pattern =
447
+ /\bimport\s+(?<typeOnly>type\s+)?\{(?<imports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
448
+
449
+ for (const match of code.matchAll(pattern)) {
450
+ if (!stringsMaskedCode.startsWith('import', match.index ?? 0)) {
451
+ continue;
452
+ }
453
+
454
+ const specifier = match.groups?.['specifier'];
455
+ if (!specifier?.startsWith('.')) {
456
+ continue;
457
+ }
458
+
459
+ const namedImports = match.groups?.['imports'] ?? '';
460
+ for (const item of namedImports.split(',')) {
461
+ const trimmedItem = item.trim();
462
+ if (!trimmedItem) {
463
+ continue;
464
+ }
465
+
466
+ const specifierTypeOnly =
467
+ Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
468
+ const specifierText = trimmedItem.replace(/^type\s+/u, '');
469
+ const imported =
470
+ /^(?<imported>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<local>[A-Za-z_$][\w$]*))?$/u.exec(
471
+ specifierText
472
+ )?.groups;
473
+ if (
474
+ !imported?.['imported'] ||
475
+ (imported['local'] ?? imported['imported']) !== identifier
476
+ ) {
477
+ continue;
478
+ }
479
+
480
+ imports.push({
481
+ imported: imported['imported'],
482
+ specifier,
483
+ typeOnly: specifierTypeOnly,
484
+ });
485
+ }
486
+ }
487
+
488
+ return imports;
489
+ };
490
+
491
+ const localNamedReexports = (
492
+ source: string,
493
+ identifier: string
494
+ ): readonly LocalNamedReexport[] => {
495
+ const code = maskSource(source, { strings: false });
496
+ const stringsMaskedCode = maskSource(source, { strings: true });
497
+ const exports: LocalNamedReexport[] = [];
498
+ const pattern =
499
+ /\bexport\s+(?<typeOnly>type\s+)?\{(?<exports>[\s\S]*?)\}\s+from\s+['"](?<specifier>[^'"]+)['"]/gu;
500
+
501
+ for (const match of code.matchAll(pattern)) {
502
+ if (!stringsMaskedCode.startsWith('export', match.index ?? 0)) {
503
+ continue;
504
+ }
505
+
506
+ const specifier = match.groups?.['specifier'];
507
+ if (!specifier?.startsWith('.')) {
508
+ continue;
509
+ }
510
+
511
+ const namedExports = match.groups?.['exports'] ?? '';
512
+ for (const item of namedExports.split(',')) {
513
+ const trimmedItem = item.trim();
514
+ if (!trimmedItem) {
515
+ continue;
516
+ }
517
+
518
+ const specifierTypeOnly =
519
+ Boolean(match.groups?.['typeOnly']) || trimmedItem.startsWith('type ');
520
+ const specifierText = trimmedItem.replace(/^type\s+/u, '');
521
+ const exported =
522
+ /^(?<local>[A-Za-z_$][\w$]*)(?:\s+as\s+(?<name>[A-Za-z_$][\w$]*))?$/u.exec(
523
+ specifierText
524
+ )?.groups;
525
+ if (
526
+ !exported?.['local'] ||
527
+ (exported['name'] ?? exported['local']) !== identifier
528
+ ) {
529
+ continue;
530
+ }
531
+
532
+ exports.push({
533
+ local: exported['local'],
534
+ specifier,
535
+ typeOnly: specifierTypeOnly,
536
+ });
537
+ }
538
+ }
539
+
540
+ return exports;
541
+ };
542
+
543
+ const localStarReexports = (
544
+ source: string
545
+ ): readonly { readonly specifier: string; readonly typeOnly: boolean }[] => {
546
+ const code = maskSource(source, { strings: false });
547
+ const stringsMaskedCode = maskSource(source, { strings: true });
548
+ return [
549
+ ...code.matchAll(
550
+ /\bexport\s+(?<typeOnly>type\s+)?\*\s+from\s+['"](?<specifier>[^'"]+)['"]/gu
551
+ ),
552
+ ]
553
+ .filter((match) => stringsMaskedCode.startsWith('export', match.index ?? 0))
554
+ .map((match) => ({
555
+ specifier: match.groups?.['specifier'] ?? '',
556
+ typeOnly: Boolean(match.groups?.['typeOnly']),
557
+ }))
558
+ .filter((entry) => entry.specifier.startsWith('.'));
559
+ };
560
+
561
+ const resolveLocalModuleSpecifier = (
562
+ sourcePath: string,
563
+ specifier: string
564
+ ): string | undefined => {
565
+ const basePath = resolve(dirname(sourcePath), specifier);
566
+ const candidates = [
567
+ basePath,
568
+ basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.ts` : undefined,
569
+ basePath.endsWith('.js') ? `${basePath.slice(0, -3)}.tsx` : undefined,
570
+ `${basePath}.ts`,
571
+ `${basePath}.tsx`,
572
+ join(basePath, 'index.ts'),
573
+ join(basePath, 'index.tsx'),
574
+ ].filter((candidate): candidate is string => candidate !== undefined);
575
+
576
+ return candidates.find((candidate) => existsSync(candidate));
577
+ };
578
+
579
+ const sourcePathExportsIdentifier = (
580
+ sourcePath: string,
581
+ identifier: string,
582
+ expected: 'type' | 'value',
583
+ visited = new Set<string>()
584
+ ): boolean => {
585
+ const normalizedSourcePath = realpathSync(sourcePath);
586
+ const visitKey = `${normalizedSourcePath}:${identifier}:${expected}`;
587
+ if (visited.has(visitKey)) {
588
+ return false;
589
+ }
590
+ visited.add(visitKey);
591
+
592
+ const source = readFileSync(normalizedSourcePath, 'utf8');
593
+ if (sourceExportsIdentifier(source, identifier, expected)) {
594
+ return true;
595
+ }
596
+
597
+ const sameFileLocal = sameFileExportListLocalForIdentifier(
598
+ source,
599
+ identifier,
600
+ expected
601
+ );
602
+ if (sameFileLocal) {
603
+ for (const localImport of localNamedImports(source, sameFileLocal)) {
604
+ if (expected === 'value' && localImport.typeOnly) {
605
+ continue;
606
+ }
607
+ const targetPath = resolveLocalModuleSpecifier(
608
+ normalizedSourcePath,
609
+ localImport.specifier
610
+ );
611
+ if (
612
+ targetPath &&
613
+ sourcePathExportsIdentifier(
614
+ targetPath,
615
+ localImport.imported,
616
+ expected,
617
+ visited
618
+ )
619
+ ) {
620
+ return true;
621
+ }
622
+ }
623
+ }
624
+
625
+ for (const reexport of localNamedReexports(source, identifier)) {
626
+ if (expected === 'value' && reexport.typeOnly) {
627
+ continue;
628
+ }
629
+ const targetPath = resolveLocalModuleSpecifier(
630
+ normalizedSourcePath,
631
+ reexport.specifier
632
+ );
633
+ if (
634
+ targetPath &&
635
+ sourcePathExportsIdentifier(targetPath, reexport.local, expected, visited)
636
+ ) {
637
+ return true;
638
+ }
639
+ }
640
+
641
+ for (const reexport of localStarReexports(source)) {
642
+ if (expected === 'value' && reexport.typeOnly) {
643
+ continue;
644
+ }
645
+ const targetPath = resolveLocalModuleSpecifier(
646
+ normalizedSourcePath,
647
+ reexport.specifier
648
+ );
649
+ if (
650
+ targetPath &&
651
+ sourcePathExportsIdentifier(targetPath, identifier, expected, visited)
652
+ ) {
653
+ return true;
654
+ }
655
+ }
656
+
657
+ return false;
658
+ };
659
+
660
+ const assertHttpOwnerSupport = (
661
+ target: AdapterTargetCatalogEntry
662
+ ): Result<void, ValidationError> => {
663
+ const rootExportTarget = packageRootExportTarget(target);
664
+ if (!rootExportTarget || !existsSync(rootExportTarget)) {
665
+ return fail(
666
+ `Adapter target "${target.key}" cannot use the HTTP create.adapter template because ${target.ownerPackage} does not expose a readable package root export.`
667
+ );
668
+ }
669
+
670
+ const requiredExports = [
671
+ ['createFetchHandler', 'value'],
672
+ ['CreateFetchHandlerOptions', 'type'],
673
+ ] as const;
674
+ const missing = requiredExports.filter(
675
+ ([identifier, expected]) =>
676
+ !sourcePathExportsIdentifier(rootExportTarget, identifier, expected)
677
+ );
678
+ if (missing.length === 0) {
679
+ return Result.ok();
680
+ }
681
+
682
+ return fail(
683
+ `Adapter target "${target.key}" cannot use the HTTP create.adapter template because ${target.ownerPackage} does not export ${missing
684
+ .map(([identifier]) => identifier)
685
+ .join(' and ')} from its package root.`
686
+ );
687
+ };
688
+
689
+ const assertHttpTemplate = (
690
+ target: AdapterTargetCatalogEntry
691
+ ): Result<void, ValidationError> => {
692
+ if (target.target !== 'http') {
693
+ return fail(
694
+ `Adapter target "${target.target}" does not yet expose a create.adapter starter template.`
695
+ );
696
+ }
697
+
698
+ return assertHttpOwnerSupport(target);
699
+ };
700
+
701
+ const assertConformanceScaffold = (
702
+ target: AdapterTargetCatalogEntry
703
+ ): Result<void, ValidationError> => {
704
+ if (target.testingImport && target.conformance) {
705
+ return Result.ok();
706
+ }
707
+
708
+ return fail(
709
+ `Adapter target "${target.key}" does not declare testingImport and conformance metadata for scaffolded conformance.`
710
+ );
711
+ };
712
+
713
+ const resolveTarget = (
714
+ rootDir: string,
715
+ target: string,
716
+ placement: CreateAdapterPlacement
717
+ ): Result<AdapterTargetCatalogEntry, ValidationError> => {
718
+ const catalog = deriveAdapterTargetCatalog(rootDir);
719
+ if (catalog.diagnostics.length > 0) {
720
+ return fail(
721
+ [
722
+ 'Adapter target catalog has diagnostics; fix them before scaffolding:',
723
+ ...catalog.diagnostics.map((entry) => `- ${entry.message}`),
724
+ ].join('\n')
725
+ );
726
+ }
727
+
728
+ const entry = catalog.targets.find(
729
+ (candidate) => candidate.target === target
730
+ );
731
+ if (entry === undefined) {
732
+ return fail(`Unknown adapter target "${target}".`);
733
+ }
734
+ if (!entry.placements.includes(placement)) {
735
+ return fail(
736
+ `Adapter target "${entry.key}" does not support ${placement} placement.`
737
+ );
738
+ }
739
+
740
+ const conformance = assertConformanceScaffold(entry);
741
+ if (conformance.isErr()) {
742
+ return conformance;
743
+ }
744
+
745
+ const template = assertHttpTemplate(entry);
746
+ if (template.isErr()) {
747
+ return template;
748
+ }
749
+
750
+ return Result.ok(entry);
751
+ };
752
+
753
+ const generateExtractedPackageJson = (
754
+ packageName: string,
755
+ target: AdapterTargetCatalogEntry
756
+ ): string =>
757
+ formatJson({
758
+ dependencies: {
759
+ '@ontrails/core': 'workspace:^',
760
+ },
761
+ exports: {
762
+ '.': './src/index.ts',
763
+ './package.json': './package.json',
764
+ },
765
+ files: [
766
+ 'src/**/*.ts',
767
+ '!src/**/__tests__/**',
768
+ '!src/**/*.test.ts',
769
+ '!src/**/*.test-d.ts',
770
+ 'README.md',
771
+ ],
772
+ name: packageName,
773
+ peerDependencies: {
774
+ [target.ownerPackage]: 'workspace:^',
775
+ },
776
+ scripts: {
777
+ build: 'tsc -b',
778
+ clean: 'rm -rf dist *.tsbuildinfo',
779
+ lint: 'oxlint ./src',
780
+ test: 'bun test',
781
+ typecheck: 'tsc --noEmit',
782
+ },
783
+ trails: {
784
+ adapter: {
785
+ target: target.target,
786
+ },
787
+ },
788
+ type: 'module',
789
+ version: trailsPackageVersion,
790
+ });
791
+
792
+ const generateExtractedTsconfig = (): string =>
793
+ formatJson({
794
+ compilerOptions: {
795
+ outDir: 'dist',
796
+ rootDir: 'src',
797
+ },
798
+ exclude: ['**/__tests__/**', '**/*.test.ts', 'dist'],
799
+ extends: '../../tsconfig.json',
800
+ include: ['src'],
801
+ });
802
+
803
+ const generateTestTsconfig = (): string =>
804
+ formatJson({
805
+ compilerOptions: {
806
+ noEmit: true,
807
+ rootDir: './src',
808
+ types: ['bun'],
809
+ },
810
+ exclude: [],
811
+ extends: './tsconfig.json',
812
+ include: ['src/**/*.test.ts', 'src/__tests__/**/*.ts'],
813
+ });
814
+
815
+ const generateHttpExtractedIndex = (
816
+ target: AdapterTargetCatalogEntry
817
+ ): string =>
818
+ `import type { Topo } from '@ontrails/core';
819
+ import { createFetchHandler } from ${literal(target.ownerPackage)};
820
+ import type { CreateFetchHandlerOptions } from ${literal(target.ownerPackage)};
821
+
822
+ export interface CreateAppOptions extends CreateFetchHandlerOptions {}
823
+
824
+ export const createApp = (
825
+ graph: Topo,
826
+ options: CreateAppOptions = {}
827
+ ) => ({
828
+ fetch: createFetchHandler(graph, options),
829
+ });
830
+ `;
831
+
832
+ const generateConformanceTest = (
833
+ target: AdapterTargetCatalogEntry,
834
+ adapterImport: string,
835
+ createAppImportPath: string
836
+ ): string => {
837
+ const conformance = target.conformance as AdapterTargetConformanceManifest;
838
+ return `import {
839
+ ${conformance.casesFactory},
840
+ ${conformance.runner},
841
+ } from ${literal(target.testingImport ?? '')};
842
+ import type { ${conformance.adapterType} } from ${literal(target.testingImport ?? '')};
843
+
844
+ import { createApp } from ${literal(createAppImportPath)};
845
+
846
+ const adapter = {
847
+ createApp,
848
+ name: ${literal(adapterImport)},
849
+ } satisfies ${conformance.adapterType};
850
+
851
+ await ${conformance.runner}(adapter, await ${conformance.casesFactory}());
852
+ `;
853
+ };
854
+
855
+ const generateReadme = (
856
+ packageName: string,
857
+ target: AdapterTargetCatalogEntry
858
+ ): string =>
859
+ `# ${packageName}
860
+
861
+ ${packageName} is a Trails ${target.target} adapter scaffold.
862
+
863
+ ## Validate
864
+
865
+ \`\`\`bash
866
+ bun test
867
+ bun run typecheck
868
+ bun run lint
869
+ trails adapter check --root-dir ../..
870
+ \`\`\`
871
+
872
+ The conformance test imports ${target.testingImport} so owner-authored cases stay current as ${target.ownerPackage} evolves.
873
+ `;
874
+
875
+ const buildExtractedPlan = (
876
+ rootDir: string,
877
+ input: CreateAdapterInput,
878
+ target: AdapterTargetCatalogEntry
879
+ ): Result<AdapterOperationPlan, Error> => {
880
+ const packageName =
881
+ input.packageName ?? defaultExtractedPackageName(input.name);
882
+ if (!packageNamePattern.test(packageName)) {
883
+ return fail(packageNameMessage);
884
+ }
885
+ const existingPackage = findWorkspacePackageName(rootDir, packageName);
886
+ if (existingPackage) {
887
+ return fail(
888
+ `Workspace package name "${existingPackage.name}" already exists at ${existingPackage.workspacePath}.`
889
+ );
890
+ }
891
+
892
+ const packageRootPath = `adapters/${input.name}`;
893
+ if (!rootWorkspaceIncludesPath(rootDir, packageRootPath)) {
894
+ return fail(
895
+ `Root package.json workspaces must include "${packageRootPath}" or "adapters/*" before create.adapter can write an extracted adapter package.`
896
+ );
897
+ }
898
+
899
+ const packageRoot = resolveProjectPath(rootDir, packageRootPath);
900
+ if (packageRoot.isErr()) {
901
+ return packageRoot;
902
+ }
903
+ if (existsSync(packageRoot.value)) {
904
+ return fail(`Adapter package already exists at ${packageRootPath}.`);
905
+ }
906
+
907
+ const adapterImport = packageName;
908
+ const operations = [
909
+ writeOperation(
910
+ `${packageRootPath}/package.json`,
911
+ generateExtractedPackageJson(packageName, target)
912
+ ),
913
+ writeOperation(
914
+ `${packageRootPath}/tsconfig.json`,
915
+ generateExtractedTsconfig()
916
+ ),
917
+ writeOperation(
918
+ `${packageRootPath}/tsconfig.tests.json`,
919
+ generateTestTsconfig()
920
+ ),
921
+ writeOperation(
922
+ `${packageRootPath}/README.md`,
923
+ generateReadme(packageName, target)
924
+ ),
925
+ writeOperation(
926
+ `${packageRootPath}/src/index.ts`,
927
+ generateHttpExtractedIndex(target)
928
+ ),
929
+ writeOperation(
930
+ `${packageRootPath}/src/__tests__/conformance.test.ts`,
931
+ generateConformanceTest(target, adapterImport, '../index.js')
932
+ ),
933
+ ];
934
+
935
+ return Result.ok({
936
+ adapterImport,
937
+ operations,
938
+ packageName,
939
+ targetKey: target.key,
940
+ });
941
+ };
942
+
943
+ const buildSubpathPlan = (
944
+ _rootDir: string,
945
+ _input: CreateAdapterInput,
946
+ _target: AdapterTargetCatalogEntry
947
+ ): Result<AdapterOperationPlan, Error> =>
948
+ fail(
949
+ 'Subpath adapter scaffolding is deferred until shared adapter checks discover subpath adapter subjects.'
950
+ );
951
+
952
+ const buildOperationPlan = (
953
+ rootDir: string,
954
+ input: CreateAdapterInput,
955
+ target: AdapterTargetCatalogEntry
956
+ ): Result<AdapterOperationPlan, Error> =>
957
+ input.placement === 'extracted'
958
+ ? buildExtractedPlan(rootDir, input, target)
959
+ : buildSubpathPlan(rootDir, input, target);
960
+
961
+ const runPlannedOperations = async (
962
+ rootDir: string,
963
+ operations: readonly ProjectWriteOperation[],
964
+ dryRun: boolean
965
+ ): Promise<Result<readonly PlannedProjectOperation[], Error>> =>
966
+ dryRun
967
+ ? planProjectOperations(rootDir, operations)
968
+ : await applyProjectOperations(rootDir, operations);
969
+
970
+ export const createAdapterTrail = trail('create.adapter', {
971
+ args: ['name'],
972
+ blaze: async (input: CreateAdapterInput, ctx) => {
973
+ const rootDirResult = resolveTrailRootDir(input.rootDir, ctx.cwd);
974
+ if (rootDirResult.isErr()) {
975
+ return rootDirResult;
976
+ }
977
+ const physicalRootDir = resolvePhysicalRootDir(rootDirResult.value);
978
+ if (physicalRootDir.isErr()) {
979
+ return physicalRootDir;
980
+ }
981
+ const rootDir = physicalRootDir.value;
982
+ const target = resolveTarget(rootDir, input.target, input.placement);
983
+ if (target.isErr()) {
984
+ return target;
985
+ }
986
+
987
+ const plan = buildOperationPlan(rootDir, input, target.value);
988
+ if (plan.isErr()) {
989
+ return plan;
990
+ }
991
+
992
+ const plannedOperations = await runPlannedOperations(
993
+ rootDir,
994
+ plan.value.operations,
995
+ input.dryRun
996
+ );
997
+ if (plannedOperations.isErr()) {
998
+ return plannedOperations;
999
+ }
1000
+
1001
+ const report = checkAdapters(rootDir);
1002
+ const created = input.dryRun
1003
+ ? []
1004
+ : plannedOperations.value
1005
+ .filter((operation) => operation.kind === 'write')
1006
+ .map((operation) => operation.path);
1007
+
1008
+ return Result.ok({
1009
+ adapterImport: plan.value.adapterImport,
1010
+ created,
1011
+ diagnostics: [...report.diagnostics],
1012
+ dryRun: input.dryRun,
1013
+ packageName: plan.value.packageName,
1014
+ placement: 'extracted',
1015
+ plannedOperations: [...plannedOperations.value],
1016
+ targetKey: plan.value.targetKey,
1017
+ } satisfies CreateAdapterResult);
1018
+ },
1019
+ description: 'Scaffold an adapter package from adapter target catalog facts',
1020
+ fields: {
1021
+ placement: {
1022
+ options: [
1023
+ {
1024
+ hint: 'Standalone package under adapters/',
1025
+ label: 'Extracted',
1026
+ value: 'extracted',
1027
+ },
1028
+ ],
1029
+ },
1030
+ },
1031
+ input: z.object({
1032
+ dryRun: z
1033
+ .boolean()
1034
+ .default(false)
1035
+ .describe('Plan adapter scaffold writes without touching disk'),
1036
+ name: z
1037
+ .string()
1038
+ .regex(adapterNamePattern, adapterNameMessage)
1039
+ .describe('Adapter name, e.g. hono'),
1040
+ packageName: z
1041
+ .string()
1042
+ .regex(packageNamePattern, packageNameMessage)
1043
+ .optional()
1044
+ .describe('Package name for extracted adapter placement'),
1045
+ placement: z
1046
+ .enum(createAdapterPlacements)
1047
+ .default('extracted')
1048
+ .describe('Adapter placement'),
1049
+ rootDir: z.string().optional().describe('Workspace root directory'),
1050
+ target: z.string().describe('Adapter target id, e.g. http'),
1051
+ }),
1052
+ intent: 'write',
1053
+ output: z.object({
1054
+ adapterImport: z.string(),
1055
+ created: z.array(z.string()).readonly(),
1056
+ diagnostics: z.array(
1057
+ z.object({
1058
+ code: z.string(),
1059
+ message: z.string(),
1060
+ packageJsonPath: z.string(),
1061
+ packageName: z.string().optional(),
1062
+ placement: z.enum(adapterTargetPlacements).optional(),
1063
+ severity: z.enum(['error', 'warn']),
1064
+ target: z.string().optional(),
1065
+ })
1066
+ ),
1067
+ dryRun: z.boolean(),
1068
+ packageName: z.string(),
1069
+ placement: z.enum(createAdapterPlacements),
1070
+ plannedOperations: z.array(
1071
+ z.discriminatedUnion('kind', [
1072
+ z.object({ kind: z.literal('mkdir'), path: z.string() }),
1073
+ z.object({
1074
+ from: z.string(),
1075
+ kind: z.literal('rename'),
1076
+ to: z.string(),
1077
+ }),
1078
+ z.object({ kind: z.literal('write'), path: z.string() }),
1079
+ ])
1080
+ ),
1081
+ targetKey: z.string(),
1082
+ }),
1083
+ permit: { scopes: ['project:write'] },
1084
+ });