@ontrails/trails 1.0.0-beta.3 → 1.0.0-beta.32

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