@ontrails/regrade 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1588 @@
1
+ /**
2
+ * Export-restructure Regrade classes (TRL-1210).
3
+ *
4
+ * The TRL-1207 surfaces-overlay cutover replaced two pre-cutover conventions
5
+ * with `surfaceOverlay()` bindings inside an app module's `trailsOverlays`
6
+ * array export:
7
+ *
8
+ * - legacy CLI alias exports (`export const cliAliases = { trailId: paths }`
9
+ * or `trailsCliAliases`) became `surfaceOverlay({ cli: { alias: trailId } })`
10
+ * bindings, and
11
+ * - call-site MCP trailhead maps became `surfaceOverlay({ mcp: { name:
12
+ * [selectors] } })` group bindings, with the call-site map surviving only as
13
+ * the richer-metadata override-in-context.
14
+ *
15
+ * These classes automate the restructure for downstream apps bridging the
16
+ * pre-1.0 gap. Occurrences the classes cannot prove safe route to
17
+ * `needs-review` with the exact target shape named — a rewrite is never
18
+ * emitted on a guess.
19
+ */
20
+
21
+ import type { AstNode, SourceEdit } from '@ontrails/source';
22
+ import {
23
+ applySourceEdits,
24
+ createSourceEdit,
25
+ getNodeArgument,
26
+ getNodeArguments,
27
+ getNodeCallee,
28
+ getNodeComputed,
29
+ getNodeDeclaration,
30
+ getNodeDeclarations,
31
+ getNodeElements,
32
+ getNodeExportKind,
33
+ getNodeExpression,
34
+ getNodeId,
35
+ getNodeInit,
36
+ getNodeKey,
37
+ getNodeKind,
38
+ getNodeLocal,
39
+ getNodeProperties,
40
+ getNodeProperty,
41
+ getNodeSource,
42
+ getNodeSpecifiers,
43
+ getNodeTypeAnnotation,
44
+ getNodeValueNode,
45
+ getNodeBodyStatements,
46
+ getStringValue,
47
+ identifierName,
48
+ isArrayExpression,
49
+ isCallExpression,
50
+ isExportNamedDeclaration,
51
+ isIdentifier,
52
+ isImportDeclaration,
53
+ isImportSpecifier,
54
+ isMemberExpression,
55
+ isObjectExpression,
56
+ isProperty,
57
+ isStringLiteral,
58
+ isVariableDeclaration,
59
+ offsetToLineColumn,
60
+ parseWithDiagnostics,
61
+ validateSourceEdits,
62
+ walkWithParents,
63
+ } from '@ontrails/source';
64
+ import type { WardenRule } from '@ontrails/warden';
65
+ import {
66
+ getWardenRuleMetadata,
67
+ isWardenSourceScanTarget,
68
+ loadProjectWardenRules,
69
+ wardenRules,
70
+ } from '@ontrails/warden';
71
+
72
+ import type {
73
+ RegradeClass,
74
+ RegradeClassContext,
75
+ RegradeClassResult,
76
+ RegradeReviewDetail,
77
+ RegradeWardenClassSet,
78
+ } from './report.js';
79
+ import { loadWardenTermRewriteClasses } from './report.js';
80
+
81
+ const EXPORT_RESTRUCTURE_FIX_CLASS = 'export-restructure';
82
+
83
+ const CLI_ALIASES_CLASS_ID = 'export-restructure:cli-aliases';
84
+ const MCP_TRAILHEADS_CLASS_ID = 'export-restructure:mcp-trailheads';
85
+
86
+ /** Legacy app-module CLI alias export names removed in TRL-1207. */
87
+ const LEGACY_CLI_ALIAS_NAMES: ReadonlySet<string> = new Set([
88
+ 'cliAliases',
89
+ 'trailsCliAliases',
90
+ ]);
91
+
92
+ const OVERLAYS_EXPORT_NAME = 'trailsOverlays';
93
+ const SURFACE_OVERLAY_NAME = 'surfaceOverlay';
94
+ const CORE_MODULE_SPECIFIER = '@ontrails/core';
95
+
96
+ /** Authored value of one surface binding: a synonym trail id or a group. */
97
+ type BindingValue = string | readonly string[];
98
+
99
+ const IDENTIFIER_KEY_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
100
+
101
+ const quoteString = (value: string): string =>
102
+ `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
103
+
104
+ const quoteKey = (key: string): string =>
105
+ IDENTIFIER_KEY_PATTERN.test(key) ? key : quoteString(key);
106
+
107
+ const bindingValueText = (value: BindingValue): string =>
108
+ typeof value === 'string'
109
+ ? quoteString(value)
110
+ : `[${value.map(quoteString).join(', ')}]`;
111
+
112
+ const bindingValuesEqual = (left: BindingValue, right: BindingValue): boolean =>
113
+ typeof left === 'string' || typeof right === 'string'
114
+ ? left === right
115
+ : left.length === right.length &&
116
+ left.every((member, index) => member === right[index]);
117
+
118
+ const sortedBindingEntries = (
119
+ bindings: ReadonlyMap<string, BindingValue>
120
+ ): readonly (readonly [string, BindingValue])[] =>
121
+ [...bindings.entries()].toSorted(([left], [right]) =>
122
+ left.localeCompare(right)
123
+ );
124
+
125
+ const bindingsObjectText = (
126
+ bindings: ReadonlyMap<string, BindingValue>,
127
+ indent: string
128
+ ): string => {
129
+ const inner = sortedBindingEntries(bindings)
130
+ .map(
131
+ ([name, value]) =>
132
+ `${indent} ${quoteKey(name)}: ${bindingValueText(value)},`
133
+ )
134
+ .join('\n');
135
+ return `{\n${inner}\n${indent}}`;
136
+ };
137
+
138
+ const bindingsObjectTextSingleLine = (
139
+ bindings: ReadonlyMap<string, BindingValue>
140
+ ): string =>
141
+ `{ ${sortedBindingEntries(bindings)
142
+ .map(([name, value]) => `${quoteKey(name)}: ${bindingValueText(value)}`)
143
+ .join(', ')} }`;
144
+
145
+ /** Compact one-line target shape used in review reasons and details. */
146
+ const surfaceOverlayTargetShape = (
147
+ surfaceKey: 'cli' | 'mcp',
148
+ bindings: ReadonlyMap<string, BindingValue>
149
+ ): string =>
150
+ `surfaceOverlay({ ${surfaceKey}: ${bindingsObjectTextSingleLine(bindings)} })`;
151
+
152
+ const lineIndent = (source: string, offset: number): string => {
153
+ const lineStart = source.lastIndexOf('\n', offset - 1) + 1;
154
+ const match = /^[ \t]*/.exec(source.slice(lineStart, offset));
155
+ return match?.[0] ?? '';
156
+ };
157
+
158
+ const spansSingleLine = (source: string, start: number, end: number): boolean =>
159
+ !source.slice(start, end).includes('\n');
160
+
161
+ const COMMENT_MARKER_PATTERN = /\/[/*]/;
162
+
163
+ const spanCarriesComments = (
164
+ source: string,
165
+ start: number,
166
+ end: number
167
+ ): boolean => COMMENT_MARKER_PATTERN.test(source.slice(start, end));
168
+
169
+ /** Unwrap `as const`, `satisfies`, and parenthesized wrappers. */
170
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
171
+ let current = node;
172
+ while (
173
+ current?.type === 'TSAsExpression' ||
174
+ current?.type === 'TSSatisfiesExpression' ||
175
+ current?.type === 'ParenthesizedExpression'
176
+ ) {
177
+ current = getNodeExpression(current) ?? getNodeArgument(current);
178
+ }
179
+ return current;
180
+ };
181
+
182
+ const propertyKeyName = (property: AstNode): string | null => {
183
+ if (!isProperty(property) || getNodeComputed(property) === true) {
184
+ return null;
185
+ }
186
+ const key = getNodeKey(property);
187
+ const name = identifierName(key);
188
+ if (name !== null) {
189
+ return name;
190
+ }
191
+ return key !== undefined && isStringLiteral(key) ? getStringValue(key) : null;
192
+ };
193
+
194
+ interface TopLevelConst {
195
+ /** Whole top-level statement (export wrapper when exported). */
196
+ readonly statement: AstNode;
197
+ readonly declarator: AstNode;
198
+ readonly name: string;
199
+ readonly exported: boolean;
200
+ readonly kind: string | undefined;
201
+ readonly init: AstNode | undefined;
202
+ }
203
+
204
+ const collectTopLevelConsts = (program: AstNode): readonly TopLevelConst[] => {
205
+ const consts: TopLevelConst[] = [];
206
+ const visitDeclaration = (
207
+ statement: AstNode,
208
+ declaration: AstNode,
209
+ exported: boolean
210
+ ): void => {
211
+ if (!isVariableDeclaration(declaration)) {
212
+ return;
213
+ }
214
+ for (const declarator of getNodeDeclarations(declaration)) {
215
+ const name = identifierName(getNodeId(declarator));
216
+ if (name === null) {
217
+ continue;
218
+ }
219
+ consts.push({
220
+ declarator,
221
+ exported,
222
+ init: getNodeInit(declarator),
223
+ kind: getNodeKind(declaration),
224
+ name,
225
+ statement,
226
+ });
227
+ }
228
+ };
229
+ for (const statement of getNodeBodyStatements(program)) {
230
+ if (
231
+ isExportNamedDeclaration(statement) &&
232
+ getNodeExportKind(statement) !== 'type'
233
+ ) {
234
+ const declaration = getNodeDeclaration(statement);
235
+ if (declaration) {
236
+ visitDeclaration(statement, declaration, true);
237
+ }
238
+ continue;
239
+ }
240
+ if (isVariableDeclaration(statement)) {
241
+ visitDeclaration(statement, statement, false);
242
+ }
243
+ }
244
+ return consts;
245
+ };
246
+
247
+ /**
248
+ * Count identifier references to `name` outside its own declarator id,
249
+ * property keys, and member-expression property positions. A non-zero count
250
+ * means deleting the declaration would break the module.
251
+ */
252
+ const countOtherReferences = (
253
+ program: AstNode,
254
+ name: string,
255
+ declaratorId?: AstNode
256
+ ): number => {
257
+ let count = 0;
258
+ walkWithParents(program, (node, context) => {
259
+ if (!isIdentifier(node) || identifierName(node) !== name) {
260
+ return;
261
+ }
262
+ if (node === declaratorId) {
263
+ return;
264
+ }
265
+ const { parent } = context;
266
+ if (
267
+ parent !== null &&
268
+ isProperty(parent) &&
269
+ getNodeKey(parent) === node &&
270
+ getNodeComputed(parent) !== true
271
+ ) {
272
+ return;
273
+ }
274
+ if (
275
+ parent !== null &&
276
+ isMemberExpression(parent) &&
277
+ getNodeProperty(parent) === node &&
278
+ getNodeComputed(parent) !== true
279
+ ) {
280
+ return;
281
+ }
282
+ count += 1;
283
+ });
284
+ return count;
285
+ };
286
+
287
+ const moduleReferencesIdentifier = (program: AstNode, name: string): boolean =>
288
+ countOtherReferences(program, name) > 0;
289
+
290
+ type BindingsParse =
291
+ | { readonly ok: true; readonly bindings: ReadonlyMap<string, BindingValue> }
292
+ | { readonly ok: false; readonly reason: string };
293
+
294
+ const bindingsParseFailure = (reason: string): BindingsParse => ({
295
+ ok: false,
296
+ reason,
297
+ });
298
+
299
+ /**
300
+ * Invert a legacy alias-map object literal (`Record<trailId,
301
+ * (string | string[])[]>`) into cli bindings (`aliasPath -> trailId`), where
302
+ * alias path segments join with `.`.
303
+ */
304
+ const invertLegacyAliasMap = (
305
+ objectNode: AstNode | undefined
306
+ ): BindingsParse => {
307
+ const object = unwrapExpression(objectNode);
308
+ if (object === undefined || !isObjectExpression(object)) {
309
+ return bindingsParseFailure('the initializer is not an object literal');
310
+ }
311
+ const bindings = new Map<string, BindingValue>();
312
+ for (const property of getNodeProperties(object)) {
313
+ const trailId = propertyKeyName(property);
314
+ if (trailId === null) {
315
+ return bindingsParseFailure(
316
+ 'a property uses a computed key, spread, or non-literal shape'
317
+ );
318
+ }
319
+ const value = unwrapExpression(getNodeValueNode(property));
320
+ if (value === undefined || !isArrayExpression(value)) {
321
+ return bindingsParseFailure(
322
+ `alias paths for "${trailId}" are not an array literal`
323
+ );
324
+ }
325
+ for (const element of getNodeElements(value)) {
326
+ const alias = unwrapExpression(element ?? undefined);
327
+ let segments: readonly string[] | null = null;
328
+ if (alias !== undefined && isStringLiteral(alias)) {
329
+ const segment = getStringValue(alias);
330
+ segments = segment === null ? null : [segment];
331
+ } else if (alias !== undefined && isArrayExpression(alias)) {
332
+ const literals = getNodeElements(alias).map((segmentNode) => {
333
+ const segment = unwrapExpression(segmentNode ?? undefined);
334
+ return segment !== undefined && isStringLiteral(segment)
335
+ ? getStringValue(segment)
336
+ : null;
337
+ });
338
+ segments = literals.every(
339
+ (segment): segment is string => segment !== null
340
+ )
341
+ ? literals
342
+ : null;
343
+ }
344
+ if (segments === null || segments.length === 0) {
345
+ return bindingsParseFailure(
346
+ `an alias path for "${trailId}" is not a string or string-array literal`
347
+ );
348
+ }
349
+ const bindingName = segments.join('.');
350
+ const existing = bindings.get(bindingName);
351
+ if (existing !== undefined && !bindingValuesEqual(existing, trailId)) {
352
+ return bindingsParseFailure(
353
+ `alias "${bindingName}" maps to multiple trails`
354
+ );
355
+ }
356
+ bindings.set(bindingName, trailId);
357
+ }
358
+ }
359
+ if (bindings.size === 0) {
360
+ return bindingsParseFailure('the alias map declares no alias paths');
361
+ }
362
+ return { bindings, ok: true };
363
+ };
364
+
365
+ /** Parse an authored bindings object literal into name -> value entries. */
366
+ const parseBindingsObject = (
367
+ objectNode: AstNode | undefined
368
+ ): BindingsParse => {
369
+ const object = unwrapExpression(objectNode);
370
+ if (object === undefined || !isObjectExpression(object)) {
371
+ return bindingsParseFailure('existing bindings are not an object literal');
372
+ }
373
+ const bindings = new Map<string, BindingValue>();
374
+ for (const property of getNodeProperties(object)) {
375
+ const name = propertyKeyName(property);
376
+ if (name === null) {
377
+ return bindingsParseFailure(
378
+ 'existing bindings use a computed key, spread, or non-literal shape'
379
+ );
380
+ }
381
+ const value = unwrapExpression(getNodeValueNode(property));
382
+ if (value !== undefined && isStringLiteral(value)) {
383
+ const literal = getStringValue(value);
384
+ if (literal === null) {
385
+ return bindingsParseFailure(
386
+ `existing binding "${name}" is not a plain string literal`
387
+ );
388
+ }
389
+ bindings.set(name, literal);
390
+ continue;
391
+ }
392
+ if (value !== undefined && isArrayExpression(value)) {
393
+ const members = getNodeElements(value).map((element) => {
394
+ const member = unwrapExpression(element ?? undefined);
395
+ return member !== undefined && isStringLiteral(member)
396
+ ? getStringValue(member)
397
+ : null;
398
+ });
399
+ if (!members.every((member): member is string => member !== null)) {
400
+ return bindingsParseFailure(
401
+ `existing binding "${name}" carries non-literal members`
402
+ );
403
+ }
404
+ bindings.set(name, members);
405
+ continue;
406
+ }
407
+ return bindingsParseFailure(
408
+ `existing binding "${name}" is not a literal string or array`
409
+ );
410
+ }
411
+ return { bindings, ok: true };
412
+ };
413
+
414
+ interface OverlaysExport {
415
+ readonly arrayNode: AstNode;
416
+ /** The `surfaceOverlay({ ... })` object argument, when one exists. */
417
+ readonly overlayObject: AstNode | undefined;
418
+ }
419
+
420
+ const findOverlaysExport = (
421
+ program: AstNode
422
+ ): { readonly parse: OverlaysExport | null; readonly declared: boolean } => {
423
+ const declarator = collectTopLevelConsts(program).find(
424
+ (candidate) => candidate.exported && candidate.name === OVERLAYS_EXPORT_NAME
425
+ );
426
+ if (declarator === undefined) {
427
+ return { declared: false, parse: null };
428
+ }
429
+ const arrayNode = unwrapExpression(declarator.init);
430
+ if (arrayNode === undefined || !isArrayExpression(arrayNode)) {
431
+ return { declared: true, parse: null };
432
+ }
433
+ for (const element of getNodeElements(arrayNode)) {
434
+ const call = unwrapExpression(element ?? undefined);
435
+ if (
436
+ call === undefined ||
437
+ !isCallExpression(call) ||
438
+ identifierName(getNodeCallee(call)) !== SURFACE_OVERLAY_NAME
439
+ ) {
440
+ continue;
441
+ }
442
+ const [argument] = getNodeArguments(call);
443
+ const overlayObject = unwrapExpression(argument);
444
+ if (overlayObject === undefined || !isObjectExpression(overlayObject)) {
445
+ return { declared: true, parse: null };
446
+ }
447
+ return { declared: true, parse: { arrayNode, overlayObject } };
448
+ }
449
+ return { declared: true, parse: { arrayNode, overlayObject: undefined } };
450
+ };
451
+
452
+ type ImportEditResult =
453
+ | { readonly ok: true; readonly edit: SourceEdit | null }
454
+ | { readonly ok: false; readonly reason: string };
455
+
456
+ const isTypeOnlyImportStatement = (
457
+ source: string,
458
+ statement: AstNode
459
+ ): boolean => /^import\s+type\b/.test(source.slice(statement.start));
460
+
461
+ const importSourceValue = (statement: AstNode): string | null => {
462
+ const sourceNode = getNodeSource(statement);
463
+ return sourceNode !== undefined && isStringLiteral(sourceNode)
464
+ ? getStringValue(sourceNode)
465
+ : null;
466
+ };
467
+
468
+ /**
469
+ * Build the edit that makes `surfaceOverlay` importable from
470
+ * `@ontrails/core`, or `null` when the import already exists.
471
+ */
472
+ const surfaceOverlayImportEdit = (
473
+ program: AstNode,
474
+ source: string
475
+ ): ImportEditResult => {
476
+ const importStatements = getNodeBodyStatements(program).filter((statement) =>
477
+ isImportDeclaration(statement)
478
+ );
479
+
480
+ for (const statement of importStatements) {
481
+ for (const specifier of getNodeSpecifiers(statement)) {
482
+ if (!isImportSpecifier(specifier)) {
483
+ continue;
484
+ }
485
+ const local = identifierName(getNodeLocal(specifier));
486
+ if (local !== SURFACE_OVERLAY_NAME) {
487
+ continue;
488
+ }
489
+ const from = importSourceValue(statement);
490
+ if (
491
+ from === CORE_MODULE_SPECIFIER &&
492
+ !isTypeOnlyImportStatement(source, statement)
493
+ ) {
494
+ return { edit: null, ok: true };
495
+ }
496
+ return {
497
+ ok: false,
498
+ reason: `the module already binds "${SURFACE_OVERLAY_NAME}" from a different module or a type-only import`,
499
+ };
500
+ }
501
+ }
502
+ if (
503
+ collectTopLevelConsts(program).some(
504
+ (candidate) => candidate.name === SURFACE_OVERLAY_NAME
505
+ )
506
+ ) {
507
+ return {
508
+ ok: false,
509
+ reason: `the module declares a conflicting "${SURFACE_OVERLAY_NAME}" binding`,
510
+ };
511
+ }
512
+
513
+ const coreImport = importStatements.find(
514
+ (statement) =>
515
+ importSourceValue(statement) === CORE_MODULE_SPECIFIER &&
516
+ !isTypeOnlyImportStatement(source, statement) &&
517
+ getNodeSpecifiers(statement).some((specifier) =>
518
+ isImportSpecifier(specifier)
519
+ )
520
+ );
521
+ if (coreImport !== undefined) {
522
+ const named = getNodeSpecifiers(coreImport).filter((specifier) =>
523
+ isImportSpecifier(specifier)
524
+ );
525
+ const multiLine = !spansSingleLine(
526
+ source,
527
+ coreImport.start,
528
+ coreImport.end
529
+ );
530
+ const anchor = named.find((specifier) => {
531
+ const local = identifierName(getNodeLocal(specifier));
532
+ return local !== null && local.localeCompare(SURFACE_OVERLAY_NAME) > 0;
533
+ });
534
+ if (anchor !== undefined) {
535
+ const text = multiLine
536
+ ? `${SURFACE_OVERLAY_NAME},\n${lineIndent(source, anchor.start)}`
537
+ : `${SURFACE_OVERLAY_NAME}, `;
538
+ return {
539
+ edit: createSourceEdit(anchor.start, anchor.start, text),
540
+ ok: true,
541
+ };
542
+ }
543
+ const last = named.at(-1);
544
+ if (last !== undefined) {
545
+ const text = multiLine
546
+ ? `,\n${lineIndent(source, last.start)}${SURFACE_OVERLAY_NAME}`
547
+ : `, ${SURFACE_OVERLAY_NAME}`;
548
+ return { edit: createSourceEdit(last.end, last.end, text), ok: true };
549
+ }
550
+ }
551
+
552
+ const statementText = `import { ${SURFACE_OVERLAY_NAME} } from '${CORE_MODULE_SPECIFIER}';\n`;
553
+ const lastImport = importStatements.at(-1);
554
+ if (lastImport !== undefined) {
555
+ return {
556
+ edit: createSourceEdit(
557
+ lastImport.end,
558
+ lastImport.end,
559
+ `\n${statementText.trimEnd()}`
560
+ ),
561
+ ok: true,
562
+ };
563
+ }
564
+ const [firstStatement] = getNodeBodyStatements(program);
565
+ const insertAt = firstStatement?.start ?? 0;
566
+ return {
567
+ edit: createSourceEdit(insertAt, insertAt, `${statementText}\n`),
568
+ ok: true,
569
+ };
570
+ };
571
+
572
+ type OverlayMergeResult =
573
+ | { readonly ok: true; readonly edit: SourceEdit; readonly note: string }
574
+ | { readonly ok: true; readonly edit: null; readonly note: string }
575
+ | { readonly ok: false; readonly reason: string };
576
+
577
+ /**
578
+ * Merge `bindings` into the `surfaceKey` bindings of an existing
579
+ * `surfaceOverlay({ ... })` object, creating the surface key when absent.
580
+ * Returns `edit: null` when the overlay already covers every binding.
581
+ */
582
+ const mergeIntoOverlayObject = (params: {
583
+ readonly bindings: ReadonlyMap<string, BindingValue>;
584
+ readonly overlayObject: AstNode;
585
+ readonly source: string;
586
+ readonly surfaceKey: 'cli' | 'mcp';
587
+ }): OverlayMergeResult => {
588
+ const { bindings, overlayObject, source, surfaceKey } = params;
589
+ const properties = getNodeProperties(overlayObject);
590
+ const surfaceProperty = properties.find(
591
+ (property) => propertyKeyName(property) === surfaceKey
592
+ );
593
+
594
+ if (surfaceProperty !== undefined) {
595
+ const valueNode = getNodeValueNode(surfaceProperty);
596
+ if (valueNode === undefined) {
597
+ return {
598
+ ok: false,
599
+ reason: `the existing ${surfaceKey} bindings are not a plain object literal`,
600
+ };
601
+ }
602
+ if (spanCarriesComments(source, valueNode.start, valueNode.end)) {
603
+ return {
604
+ ok: false,
605
+ reason: `the existing ${surfaceKey} bindings carry comments a mechanical merge would drop`,
606
+ };
607
+ }
608
+ const existing = parseBindingsObject(valueNode);
609
+ if (!existing.ok) {
610
+ return { ok: false, reason: existing.reason };
611
+ }
612
+ const merged = new Map(existing.bindings);
613
+ let added = 0;
614
+ for (const [name, value] of bindings) {
615
+ const current = merged.get(name);
616
+ if (current === undefined) {
617
+ merged.set(name, value);
618
+ added += 1;
619
+ continue;
620
+ }
621
+ if (!bindingValuesEqual(current, value)) {
622
+ return {
623
+ ok: false,
624
+ reason: `the existing ${surfaceKey} binding "${name}" conflicts with the migrated value`,
625
+ };
626
+ }
627
+ }
628
+ if (added === 0) {
629
+ return {
630
+ edit: null,
631
+ note: `Existing surfaceOverlay ${surfaceKey} bindings already cover the migrated entries.`,
632
+ ok: true,
633
+ };
634
+ }
635
+ const indent = lineIndent(source, surfaceProperty.start);
636
+ const text = spansSingleLine(source, valueNode.start, valueNode.end)
637
+ ? bindingsObjectTextSingleLine(merged)
638
+ : bindingsObjectText(merged, indent);
639
+ return {
640
+ edit: createSourceEdit(valueNode.start, valueNode.end, text),
641
+ note: `Merged ${added} ${surfaceKey} binding(s) into the existing surfaceOverlay entry.`,
642
+ ok: true,
643
+ };
644
+ }
645
+
646
+ const singleLine = spansSingleLine(
647
+ source,
648
+ overlayObject.start,
649
+ overlayObject.end
650
+ );
651
+ const anchor = properties.find((property) => {
652
+ const name = propertyKeyName(property);
653
+ return name !== null && name.localeCompare(surfaceKey) > 0;
654
+ });
655
+ if (anchor !== undefined) {
656
+ const indent = lineIndent(source, anchor.start);
657
+ const text = singleLine
658
+ ? `${surfaceKey}: ${bindingsObjectTextSingleLine(bindings)}, `
659
+ : `${surfaceKey}: ${bindingsObjectText(bindings, indent)},\n${indent}`;
660
+ return {
661
+ edit: createSourceEdit(anchor.start, anchor.start, text),
662
+ note: `Added ${surfaceKey} bindings to the existing surfaceOverlay entry.`,
663
+ ok: true,
664
+ };
665
+ }
666
+ const last = properties.at(-1);
667
+ if (last !== undefined) {
668
+ const indent = lineIndent(source, last.start);
669
+ const text = singleLine
670
+ ? `, ${surfaceKey}: ${bindingsObjectTextSingleLine(bindings)}`
671
+ : `,\n${indent}${surfaceKey}: ${bindingsObjectText(bindings, indent)}`;
672
+ return {
673
+ edit: createSourceEdit(last.end, last.end, text),
674
+ note: `Added ${surfaceKey} bindings to the existing surfaceOverlay entry.`,
675
+ ok: true,
676
+ };
677
+ }
678
+ const indent = lineIndent(source, overlayObject.start);
679
+ const text = singleLine
680
+ ? `{ ${surfaceKey}: ${bindingsObjectTextSingleLine(bindings)} }`
681
+ : `{\n${indent} ${surfaceKey}: ${bindingsObjectText(bindings, `${indent} `)},\n${indent}}`;
682
+ return {
683
+ edit: createSourceEdit(overlayObject.start, overlayObject.end, text),
684
+ note: `Added ${surfaceKey} bindings to the existing surfaceOverlay entry.`,
685
+ ok: true,
686
+ };
687
+ };
688
+
689
+ /** Append a `surfaceOverlay({ surfaceKey: ... })` element to the array. */
690
+ const appendOverlayElementEdit = (params: {
691
+ readonly arrayNode: AstNode;
692
+ readonly bindings: ReadonlyMap<string, BindingValue>;
693
+ readonly source: string;
694
+ readonly surfaceKey: 'cli' | 'mcp';
695
+ }): SourceEdit => {
696
+ const { arrayNode, bindings, source, surfaceKey } = params;
697
+ const elements = getNodeElements(arrayNode).filter(
698
+ (element): element is AstNode => element !== null
699
+ );
700
+ const last = elements.at(-1);
701
+ if (last === undefined) {
702
+ const indent = lineIndent(source, arrayNode.start);
703
+ const text = `[\n${indent} ${SURFACE_OVERLAY_NAME}({\n${indent} ${surfaceKey}: ${bindingsObjectText(bindings, `${indent} `)},\n${indent} }),\n${indent}]`;
704
+ return createSourceEdit(arrayNode.start, arrayNode.end, text);
705
+ }
706
+ if (spansSingleLine(source, arrayNode.start, arrayNode.end)) {
707
+ const text = `, ${SURFACE_OVERLAY_NAME}({ ${surfaceKey}: ${bindingsObjectTextSingleLine(bindings)} })`;
708
+ return createSourceEdit(last.end, last.end, text);
709
+ }
710
+ const indent = lineIndent(source, last.start);
711
+ // No trailing comma on the inserted element: the insertion lands at
712
+ // `last.end`, BEFORE any trailing comma already in the source, so emitting
713
+ // one here would produce `}),,` — a sparse-array hole.
714
+ const text = `,\n${indent}${SURFACE_OVERLAY_NAME}({\n${indent} ${surfaceKey}: ${bindingsObjectText(bindings, `${indent} `)},\n${indent}})`;
715
+ return createSourceEdit(last.end, last.end, text);
716
+ };
717
+
718
+ /** Replacement text for a brand-new `trailsOverlays` export. */
719
+ const overlaysExportText = (
720
+ surfaceKey: 'cli' | 'mcp',
721
+ bindings: ReadonlyMap<string, BindingValue>
722
+ ): string =>
723
+ `export const ${OVERLAYS_EXPORT_NAME} = [\n ${SURFACE_OVERLAY_NAME}({\n ${surfaceKey}: ${bindingsObjectText(bindings, ' ')},\n }),\n];`;
724
+
725
+ /** Statement span extended over trailing horizontal whitespace + newline. */
726
+ const statementDeletionEdit = (
727
+ source: string,
728
+ statement: AstNode
729
+ ): SourceEdit => {
730
+ let { end } = statement;
731
+ while (end < source.length && (source[end] === ' ' || source[end] === '\t')) {
732
+ end += 1;
733
+ }
734
+ if (source[end] === '\n') {
735
+ end += 1;
736
+ }
737
+ return createSourceEdit(statement.start, end, '');
738
+ };
739
+
740
+ const reviewDetailAt = (params: {
741
+ readonly expectedTarget: string;
742
+ readonly node: AstNode;
743
+ readonly reason: string;
744
+ readonly source: string;
745
+ readonly symbol: string;
746
+ }): RegradeReviewDetail => {
747
+ const location = offsetToLineColumn(params.source, params.node.start);
748
+ return {
749
+ expectedTarget: params.expectedTarget,
750
+ nodeKind: params.node.type,
751
+ reason: params.reason,
752
+ span: {
753
+ column: location.column,
754
+ end: params.node.end,
755
+ line: location.line,
756
+ start: params.node.start,
757
+ },
758
+ suggestedValidation: 'bun run typecheck && trails compile',
759
+ symbol: params.symbol,
760
+ };
761
+ };
762
+
763
+ const needsReview = (params: {
764
+ readonly detail: RegradeReviewDetail;
765
+ readonly notes: readonly string[];
766
+ readonly reason: string;
767
+ }): RegradeClassResult => ({
768
+ kind: 'needs-review',
769
+ notes: params.notes,
770
+ reason: params.reason,
771
+ reviewDetails: [params.detail],
772
+ });
773
+
774
+ const scanTargetSkip = (
775
+ context: RegradeClassContext | undefined
776
+ ): RegradeClassResult | null => {
777
+ const path = context?.path ?? context?.absolutePath ?? '<regrade-source>';
778
+ if (isWardenSourceScanTarget(path)) {
779
+ return null;
780
+ }
781
+ return {
782
+ kind: 'skipped',
783
+ notes: ['Skipped by Warden source scan-target filtering.'],
784
+ reason: 'warden-scan-target-filtered',
785
+ };
786
+ };
787
+
788
+ const parseFailureResult = (
789
+ path: string,
790
+ diagnostics: readonly { readonly message: string }[]
791
+ ): RegradeClassResult => ({
792
+ kind: 'needs-review',
793
+ notes:
794
+ diagnostics.length > 0
795
+ ? diagnostics.map(
796
+ (diagnostic) =>
797
+ `Could not safely parse ${path}: ${diagnostic.message}`
798
+ )
799
+ : [`Could not parse ${path} for export restructure.`],
800
+ reason: 'export-restructure-parse-failed',
801
+ });
802
+
803
+ const cliAliasTargetSummary = (
804
+ bindings: ReadonlyMap<string, BindingValue> | null
805
+ ): string =>
806
+ bindings === null
807
+ ? `wrap the inverted alias map into surfaceOverlay({ cli: { '<alias.path>': '<trail.id>' } }) inside the module's trailsOverlays array export`
808
+ : `wrap into ${surfaceOverlayTargetShape('cli', bindings)} inside the module's trailsOverlays array export`;
809
+
810
+ interface CliAliasReview {
811
+ readonly note: string;
812
+ readonly reason: string;
813
+ }
814
+
815
+ /**
816
+ * The guard chain that decides whether a legacy alias declaration is the
817
+ * provable exported app-module convention. Returns a review classification
818
+ * when a rewrite cannot be proven safe.
819
+ */
820
+ const cliAliasCandidateReview = (
821
+ program: AstNode,
822
+ candidate: TopLevelConst,
823
+ inverted: BindingsParse,
824
+ targetSummary: string
825
+ ): CliAliasReview | null => {
826
+ if (!inverted.ok) {
827
+ return {
828
+ note: `Legacy CLI alias map "${candidate.name}" could not be proven safe (${inverted.reason}); ${targetSummary}.`,
829
+ reason: 'cli-aliases-not-statically-provable',
830
+ };
831
+ }
832
+ const references = countOtherReferences(
833
+ program,
834
+ candidate.name,
835
+ getNodeId(candidate.declarator)
836
+ );
837
+ if (references > 0) {
838
+ return {
839
+ note: `Legacy CLI alias map "${candidate.name}" is referenced ${references} time(s) in this module (for example a surface-option aliases: usage); ${targetSummary} and pass overlays to the surface call instead.`,
840
+ reason: 'cli-aliases-referenced-in-module',
841
+ };
842
+ }
843
+ if (!candidate.exported) {
844
+ return {
845
+ note: `Legacy CLI alias map "${candidate.name}" is a local const, not the exported app-module convention; ${targetSummary}.`,
846
+ reason: 'cli-aliases-const-not-exported',
847
+ };
848
+ }
849
+ if (candidate.kind !== 'const') {
850
+ return {
851
+ note: `Legacy CLI alias export "${candidate.name}" uses a mutable ${candidate.kind ?? 'binding'}; ${targetSummary}.`,
852
+ reason: 'cli-aliases-mutable-binding',
853
+ };
854
+ }
855
+ return null;
856
+ };
857
+
858
+ type CliAliasRestructure =
859
+ | {
860
+ readonly ok: true;
861
+ readonly edits: SourceEdit[];
862
+ readonly notes: string[];
863
+ }
864
+ | { readonly ok: false; readonly review: CliAliasReview };
865
+
866
+ /** Build the overlay-side edits for a proven legacy alias export. */
867
+ const buildCliAliasRestructure = (params: {
868
+ readonly bindings: ReadonlyMap<string, BindingValue>;
869
+ readonly candidate: TopLevelConst;
870
+ readonly program: AstNode;
871
+ readonly source: string;
872
+ readonly targetSummary: string;
873
+ }): CliAliasRestructure => {
874
+ const { bindings, candidate, program, source, targetSummary } = params;
875
+ const overlays = findOverlaysExport(program);
876
+ const edits: SourceEdit[] = [];
877
+ const notes: string[] = [];
878
+
879
+ if (!overlays.declared) {
880
+ edits.push(
881
+ createSourceEdit(
882
+ candidate.statement.start,
883
+ candidate.statement.end,
884
+ overlaysExportText('cli', bindings)
885
+ )
886
+ );
887
+ notes.push(
888
+ `Replaced legacy "${candidate.name}" export with surfaceOverlay({ cli }) bindings inside a new trailsOverlays export.`
889
+ );
890
+ return { edits, notes, ok: true };
891
+ }
892
+ if (overlays.parse === null) {
893
+ return {
894
+ ok: false,
895
+ review: {
896
+ note: `The module's trailsOverlays export is not a statically provable array literal; ${targetSummary}.`,
897
+ reason: 'cli-aliases-overlays-not-statically-provable',
898
+ },
899
+ };
900
+ }
901
+ if (overlays.parse.overlayObject === undefined) {
902
+ edits.push(
903
+ appendOverlayElementEdit({
904
+ arrayNode: overlays.parse.arrayNode,
905
+ bindings,
906
+ source,
907
+ surfaceKey: 'cli',
908
+ })
909
+ );
910
+ edits.push(statementDeletionEdit(source, candidate.statement));
911
+ notes.push(
912
+ `Appended surfaceOverlay({ cli }) to trailsOverlays and removed the legacy "${candidate.name}" export.`
913
+ );
914
+ return { edits, notes, ok: true };
915
+ }
916
+ const merge = mergeIntoOverlayObject({
917
+ bindings,
918
+ overlayObject: overlays.parse.overlayObject,
919
+ source,
920
+ surfaceKey: 'cli',
921
+ });
922
+ if (!merge.ok) {
923
+ return {
924
+ ok: false,
925
+ review: {
926
+ note: `${merge.reason}; ${targetSummary}.`,
927
+ reason: 'cli-aliases-overlay-merge-conflict',
928
+ },
929
+ };
930
+ }
931
+ if (merge.edit !== null) {
932
+ edits.push(merge.edit);
933
+ }
934
+ edits.push(statementDeletionEdit(source, candidate.statement));
935
+ notes.push(merge.note);
936
+ notes.push(`Removed the legacy "${candidate.name}" export.`);
937
+ return { edits, notes, ok: true };
938
+ };
939
+
940
+ const applyValidatedEdits = (
941
+ source: string,
942
+ edits: readonly SourceEdit[],
943
+ notes: readonly string[]
944
+ ): RegradeClassResult | { readonly failure: string } => {
945
+ try {
946
+ validateSourceEdits(edits);
947
+ return {
948
+ kind: 'rewrite',
949
+ nextSource: applySourceEdits(source, edits),
950
+ notes,
951
+ };
952
+ } catch (error) {
953
+ return {
954
+ failure:
955
+ error instanceof Error
956
+ ? `Export restructure edits could not be applied: ${error.message}`
957
+ : 'Export restructure edits could not be applied.',
958
+ };
959
+ }
960
+ };
961
+
962
+ const applyCliAliasesClass = (
963
+ source: string,
964
+ context: RegradeClassContext | undefined
965
+ ): RegradeClassResult => {
966
+ const skip = scanTargetSkip(context);
967
+ if (skip !== null) {
968
+ return skip;
969
+ }
970
+ if (!source.includes('cliAliases') && !source.includes('trailsCliAliases')) {
971
+ return { kind: 'no-op', notes: ['No legacy CLI alias exports found.'] };
972
+ }
973
+ const path = context?.path ?? '<regrade-source>';
974
+ const parsed = parseWithDiagnostics(path, source);
975
+ if (!parsed.ast || parsed.diagnostics.length > 0) {
976
+ return parseFailureResult(path, parsed.diagnostics);
977
+ }
978
+ const program = parsed.ast;
979
+ const candidates = collectTopLevelConsts(program).filter((candidate) =>
980
+ LEGACY_CLI_ALIAS_NAMES.has(candidate.name)
981
+ );
982
+ const [candidate] = candidates;
983
+ if (candidate === undefined) {
984
+ return {
985
+ kind: 'no-op',
986
+ notes: ['No legacy CLI alias declarations found.'],
987
+ };
988
+ }
989
+ if (candidates.length > 1) {
990
+ return needsReview({
991
+ detail: reviewDetailAt({
992
+ expectedTarget: cliAliasTargetSummary(null),
993
+ node: candidate.statement,
994
+ reason: 'cli-aliases-multiple-declarations',
995
+ source,
996
+ symbol: candidate.name,
997
+ }),
998
+ notes: [
999
+ `Found ${candidates.length} legacy CLI alias declarations; ${cliAliasTargetSummary(null)}.`,
1000
+ ],
1001
+ reason: 'cli-aliases-multiple-declarations',
1002
+ });
1003
+ }
1004
+
1005
+ const inverted = invertLegacyAliasMap(candidate.init);
1006
+ const targetSummary = cliAliasTargetSummary(
1007
+ inverted.ok ? inverted.bindings : null
1008
+ );
1009
+ const reviewFor = (review: CliAliasReview): RegradeClassResult =>
1010
+ needsReview({
1011
+ detail: reviewDetailAt({
1012
+ expectedTarget: `${targetSummary}.`,
1013
+ node: candidate.statement,
1014
+ reason: review.reason,
1015
+ source,
1016
+ symbol: candidate.name,
1017
+ }),
1018
+ notes: [review.note],
1019
+ reason: review.reason,
1020
+ });
1021
+
1022
+ const guardReview = cliAliasCandidateReview(
1023
+ program,
1024
+ candidate,
1025
+ inverted,
1026
+ targetSummary
1027
+ );
1028
+ if (guardReview !== null || !inverted.ok) {
1029
+ return reviewFor(
1030
+ guardReview ?? {
1031
+ note: `Legacy CLI alias map "${candidate.name}" could not be proven safe; ${targetSummary}.`,
1032
+ reason: 'cli-aliases-not-statically-provable',
1033
+ }
1034
+ );
1035
+ }
1036
+
1037
+ const importEdit = surfaceOverlayImportEdit(program, source);
1038
+ if (!importEdit.ok) {
1039
+ return reviewFor({
1040
+ note: `${importEdit.reason}; ${targetSummary}.`,
1041
+ reason: 'cli-aliases-import-conflict',
1042
+ });
1043
+ }
1044
+
1045
+ const restructure = buildCliAliasRestructure({
1046
+ bindings: inverted.bindings,
1047
+ candidate,
1048
+ program,
1049
+ source,
1050
+ targetSummary,
1051
+ });
1052
+ if (!restructure.ok) {
1053
+ return reviewFor(restructure.review);
1054
+ }
1055
+ const edits = [...restructure.edits];
1056
+ if (importEdit.edit !== null) {
1057
+ edits.push(importEdit.edit);
1058
+ }
1059
+ const applied = applyValidatedEdits(source, edits, restructure.notes);
1060
+ if ('failure' in applied) {
1061
+ return reviewFor({
1062
+ note: applied.failure,
1063
+ reason: 'cli-aliases-invalid-edits',
1064
+ });
1065
+ }
1066
+ return applied;
1067
+ };
1068
+
1069
+ /**
1070
+ * Invert legacy `cliAliases` / `trailsCliAliases` alias-map exports into
1071
+ * `surfaceOverlay({ cli: { ... } })` bindings inside the module's
1072
+ * `trailsOverlays` array export, adding the `surfaceOverlay` import from
1073
+ * `@ontrails/core` and deleting the legacy export. Occurrences that cannot be
1074
+ * proven safe — computed keys, spreads, non-literal values, in-module
1075
+ * references such as a surface-option `aliases:` usage, or a non-exported
1076
+ * const — route to `needs-review` with the exact target shape named.
1077
+ *
1078
+ * @example
1079
+ * ```ts
1080
+ * import { cliAliasesExportRestructureClass } from '@ontrails/regrade';
1081
+ *
1082
+ * const result = cliAliasesExportRestructureClass.apply(
1083
+ * "export const trailsCliAliases = { 'survey.diff': [['diff']] };",
1084
+ * { path: 'apps/example/src/app.ts' }
1085
+ * );
1086
+ * // result.kind === 'rewrite'; result.nextSource wraps the inverted map into
1087
+ * // surfaceOverlay({ cli: { diff: 'survey.diff' } }) inside trailsOverlays.
1088
+ * ```
1089
+ */
1090
+ export const cliAliasesExportRestructureClass: RegradeClass = {
1091
+ apply: applyCliAliasesClass,
1092
+ describe:
1093
+ 'Invert legacy cliAliases/trailsCliAliases exports into surfaceOverlay({ cli }) bindings inside trailsOverlays (review export-restructure).',
1094
+ id: CLI_ALIASES_CLASS_ID,
1095
+ };
1096
+
1097
+ interface TrailheadMapCandidate {
1098
+ readonly candidate: TopLevelConst;
1099
+ readonly groups: ReadonlyMap<string, BindingValue> | null;
1100
+ readonly failureReason: string | null;
1101
+ }
1102
+
1103
+ const isTrailheadMapBindingName = (name: string): boolean =>
1104
+ name === 'trailheads' ||
1105
+ name.endsWith('Trailheads') ||
1106
+ name.endsWith('TrailheadMap');
1107
+
1108
+ const TRAILHEAD_MAP_TYPE_PATTERN = /\bMcpSurfaceTrailheadMap\b/;
1109
+
1110
+ /**
1111
+ * Whether the declarator is explicitly typed as a trailhead map: an id type
1112
+ * annotation or an `as`/`satisfies` wrapper naming `McpSurfaceTrailheadMap`.
1113
+ * Checked on the annotation and wrapper spans only, so string or comment
1114
+ * mentions of the type name elsewhere in the declarator never match.
1115
+ */
1116
+ const declaratorHasTrailheadMapType = (
1117
+ source: string,
1118
+ candidate: TopLevelConst
1119
+ ): boolean => {
1120
+ // The raw AST field can be null; guard on truthiness before slicing spans.
1121
+ const annotation = getNodeTypeAnnotation(getNodeId(candidate.declarator));
1122
+ if (
1123
+ annotation &&
1124
+ TRAILHEAD_MAP_TYPE_PATTERN.test(
1125
+ source.slice(annotation.start, annotation.end)
1126
+ )
1127
+ ) {
1128
+ return true;
1129
+ }
1130
+ if (candidate.init === undefined) {
1131
+ return false;
1132
+ }
1133
+ const inner = unwrapExpression(candidate.init);
1134
+ if (inner === undefined || inner === candidate.init) {
1135
+ return false;
1136
+ }
1137
+ return TRAILHEAD_MAP_TYPE_PATTERN.test(
1138
+ source.slice(inner.end, candidate.init.end)
1139
+ );
1140
+ };
1141
+
1142
+ /**
1143
+ * Whether an object literal is shaped like a trailhead map: at least one
1144
+ * entry whose definition object carries a `trails` key. Guards name-convention
1145
+ * matches so unrelated objects named `*Trailheads` are not flagged.
1146
+ */
1147
+ const objectLooksLikeTrailheadMap = (object: AstNode): boolean =>
1148
+ getNodeProperties(object).some((property) => {
1149
+ const definition = unwrapExpression(getNodeValueNode(property));
1150
+ return (
1151
+ definition !== undefined &&
1152
+ isObjectExpression(definition) &&
1153
+ getNodeProperties(definition).some(
1154
+ (definitionProperty) => propertyKeyName(definitionProperty) === 'trails'
1155
+ )
1156
+ );
1157
+ });
1158
+
1159
+ const parseTrailheadGroups = (
1160
+ objectNode: AstNode | undefined
1161
+ ): BindingsParse => {
1162
+ const object = unwrapExpression(objectNode);
1163
+ if (object === undefined || !isObjectExpression(object)) {
1164
+ return bindingsParseFailure('the trailhead map is not an object literal');
1165
+ }
1166
+ const groups = new Map<string, BindingValue>();
1167
+ for (const property of getNodeProperties(object)) {
1168
+ const name = propertyKeyName(property);
1169
+ if (name === null) {
1170
+ return bindingsParseFailure(
1171
+ 'a trailhead entry uses a computed key, spread, or non-literal shape'
1172
+ );
1173
+ }
1174
+ const definition = unwrapExpression(getNodeValueNode(property));
1175
+ if (definition === undefined || !isObjectExpression(definition)) {
1176
+ return bindingsParseFailure(
1177
+ `trailhead "${name}" is not an object-literal definition`
1178
+ );
1179
+ }
1180
+ const trailsProperty = getNodeProperties(definition).find(
1181
+ (definitionProperty) => propertyKeyName(definitionProperty) === 'trails'
1182
+ );
1183
+ if (trailsProperty === undefined) {
1184
+ return bindingsParseFailure(
1185
+ `trailhead "${name}" declares no literal trails selector list`
1186
+ );
1187
+ }
1188
+ const selectorsNode = unwrapExpression(getNodeValueNode(trailsProperty));
1189
+ if (selectorsNode === undefined || !isArrayExpression(selectorsNode)) {
1190
+ return bindingsParseFailure(
1191
+ `trailhead "${name}" uses a dynamic trails selector`
1192
+ );
1193
+ }
1194
+ const selectors = getNodeElements(selectorsNode).map((element) => {
1195
+ const selector = unwrapExpression(element ?? undefined);
1196
+ return selector !== undefined && isStringLiteral(selector)
1197
+ ? getStringValue(selector)
1198
+ : null;
1199
+ });
1200
+ if (
1201
+ !selectors.every((selector): selector is string => selector !== null) ||
1202
+ selectors.length === 0
1203
+ ) {
1204
+ return bindingsParseFailure(
1205
+ `trailhead "${name}" carries non-literal trails selectors`
1206
+ );
1207
+ }
1208
+ groups.set(name, selectors);
1209
+ }
1210
+ if (groups.size === 0) {
1211
+ return bindingsParseFailure('the trailhead map declares no entries');
1212
+ }
1213
+ return { bindings: groups, ok: true };
1214
+ };
1215
+
1216
+ const collectTrailheadMapCandidates = (
1217
+ program: AstNode,
1218
+ source: string
1219
+ ): readonly TrailheadMapCandidate[] =>
1220
+ collectTopLevelConsts(program).flatMap((candidate) => {
1221
+ const typed = declaratorHasTrailheadMapType(source, candidate);
1222
+ const object = unwrapExpression(candidate.init);
1223
+ const isObjectLiteral = object !== undefined && isObjectExpression(object);
1224
+ // A candidate is an object-literal map matching the naming convention or
1225
+ // the explicit type, or an explicitly typed dynamic value. Helper
1226
+ // functions and unrelated values that merely share the naming suffix are
1227
+ // not migration targets.
1228
+ const isCandidate =
1229
+ (isObjectLiteral &&
1230
+ (typed ||
1231
+ (isTrailheadMapBindingName(candidate.name) &&
1232
+ objectLooksLikeTrailheadMap(object)))) ||
1233
+ (typed && !isObjectLiteral);
1234
+ if (!isCandidate) {
1235
+ return [];
1236
+ }
1237
+ const parsedGroups = parseTrailheadGroups(candidate.init);
1238
+ return [
1239
+ parsedGroups.ok
1240
+ ? { candidate, failureReason: null, groups: parsedGroups.bindings }
1241
+ : { candidate, failureReason: parsedGroups.reason, groups: null },
1242
+ ];
1243
+ });
1244
+
1245
+ const mcpTargetSummary = (
1246
+ groups: ReadonlyMap<string, BindingValue> | null
1247
+ ): string =>
1248
+ groups === null
1249
+ ? `author surfaceOverlay({ mcp: { '<name>': ['<trail.id>', ...] } }) in the app module's trailsOverlays array export; keep this call-site trailhead map as the runtime override-in-context`
1250
+ : `author ${surfaceOverlayTargetShape('mcp', groups)} in the app module's trailsOverlays array export; keep this call-site trailhead map as the runtime override-in-context`;
1251
+
1252
+ interface McpReviewParams {
1253
+ readonly node: AstNode;
1254
+ readonly note: string;
1255
+ readonly reason: string;
1256
+ readonly symbol: string;
1257
+ readonly target: string;
1258
+ }
1259
+
1260
+ const mcpNeedsReview = (
1261
+ source: string,
1262
+ params: McpReviewParams
1263
+ ): RegradeClassResult =>
1264
+ needsReview({
1265
+ detail: reviewDetailAt({
1266
+ expectedTarget: `${params.target}.`,
1267
+ node: params.node,
1268
+ reason: params.reason,
1269
+ source,
1270
+ symbol: params.symbol,
1271
+ }),
1272
+ notes: [params.note],
1273
+ reason: params.reason,
1274
+ });
1275
+
1276
+ type TrailheadGroupsMerge =
1277
+ | { readonly ok: true; readonly groups: ReadonlyMap<string, BindingValue> }
1278
+ | { readonly ok: false; readonly review: McpReviewParams };
1279
+
1280
+ /** Merge every candidate map's groups, flagging unprovable or conflicting entries. */
1281
+ const mergeTrailheadGroups = (
1282
+ mapCandidates: readonly TrailheadMapCandidate[]
1283
+ ): TrailheadGroupsMerge => {
1284
+ const unprovable = mapCandidates.find(
1285
+ (entry) => entry.failureReason !== null
1286
+ );
1287
+ if (unprovable !== undefined) {
1288
+ return {
1289
+ ok: false,
1290
+ review: {
1291
+ node: unprovable.candidate.statement,
1292
+ note: `Trailhead map "${unprovable.candidate.name}" could not be proven safe (${unprovable.failureReason}); ${mcpTargetSummary(null)}.`,
1293
+ reason: 'mcp-trailheads-not-statically-provable',
1294
+ symbol: unprovable.candidate.name,
1295
+ target: mcpTargetSummary(null),
1296
+ },
1297
+ };
1298
+ }
1299
+ const groups = new Map<string, BindingValue>();
1300
+ for (const entry of mapCandidates) {
1301
+ for (const [name, members] of entry.groups ?? []) {
1302
+ const existing = groups.get(name);
1303
+ if (existing !== undefined && !bindingValuesEqual(existing, members)) {
1304
+ return {
1305
+ ok: false,
1306
+ review: {
1307
+ node: entry.candidate.statement,
1308
+ note: `Trailhead "${name}" is declared with conflicting members across maps; ${mcpTargetSummary(null)}.`,
1309
+ reason: 'mcp-trailheads-conflicting-groups',
1310
+ symbol: name,
1311
+ target: mcpTargetSummary(null),
1312
+ },
1313
+ };
1314
+ }
1315
+ groups.set(name, members);
1316
+ }
1317
+ }
1318
+ return { groups, ok: true };
1319
+ };
1320
+
1321
+ /** Rewrite path: the module exports `trailsOverlays`, so merge in place. */
1322
+ const rewriteTrailheadsIntoOverlays = (params: {
1323
+ readonly anchor: AstNode;
1324
+ readonly anchorSymbol: string;
1325
+ readonly groups: ReadonlyMap<string, BindingValue>;
1326
+ readonly overlays: ReturnType<typeof findOverlaysExport>;
1327
+ readonly program: AstNode;
1328
+ readonly source: string;
1329
+ }): RegradeClassResult => {
1330
+ const { anchor, anchorSymbol, groups, overlays, program, source } = params;
1331
+ const target = mcpTargetSummary(groups);
1332
+ if (overlays.parse === null) {
1333
+ return mcpNeedsReview(source, {
1334
+ node: anchor,
1335
+ note: `The module's trailsOverlays export is not a statically provable array literal; ${target}.`,
1336
+ reason: 'mcp-trailheads-overlays-not-statically-provable',
1337
+ symbol: anchorSymbol,
1338
+ target,
1339
+ });
1340
+ }
1341
+ const importEdit = surfaceOverlayImportEdit(program, source);
1342
+ if (!importEdit.ok) {
1343
+ return mcpNeedsReview(source, {
1344
+ node: anchor,
1345
+ note: `${importEdit.reason}; ${target}.`,
1346
+ reason: 'mcp-trailheads-import-conflict',
1347
+ symbol: anchorSymbol,
1348
+ target,
1349
+ });
1350
+ }
1351
+
1352
+ const edits: SourceEdit[] = [];
1353
+ const notes: string[] = [];
1354
+ if (overlays.parse.overlayObject === undefined) {
1355
+ edits.push(
1356
+ appendOverlayElementEdit({
1357
+ arrayNode: overlays.parse.arrayNode,
1358
+ bindings: groups,
1359
+ source,
1360
+ surfaceKey: 'mcp',
1361
+ })
1362
+ );
1363
+ notes.push(
1364
+ 'Appended surfaceOverlay({ mcp }) group bindings to trailsOverlays; the call-site trailhead map stays as the runtime override-in-context.'
1365
+ );
1366
+ } else {
1367
+ const merge = mergeIntoOverlayObject({
1368
+ bindings: groups,
1369
+ overlayObject: overlays.parse.overlayObject,
1370
+ source,
1371
+ surfaceKey: 'mcp',
1372
+ });
1373
+ if (!merge.ok) {
1374
+ return mcpNeedsReview(source, {
1375
+ node: anchor,
1376
+ note: `${merge.reason}; ${target}.`,
1377
+ reason: 'mcp-trailheads-overlay-merge-conflict',
1378
+ symbol: anchorSymbol,
1379
+ target,
1380
+ });
1381
+ }
1382
+ if (merge.edit === null) {
1383
+ return {
1384
+ kind: 'no-op',
1385
+ notes: [
1386
+ 'Module overlay already covers the trailhead map; the call-site map stays as the runtime override-in-context.',
1387
+ ],
1388
+ };
1389
+ }
1390
+ edits.push(merge.edit);
1391
+ notes.push(
1392
+ `${merge.note} The call-site trailhead map stays as the runtime override-in-context.`
1393
+ );
1394
+ }
1395
+ if (importEdit.edit !== null) {
1396
+ edits.push(importEdit.edit);
1397
+ }
1398
+ const applied = applyValidatedEdits(source, edits, notes);
1399
+ if ('failure' in applied) {
1400
+ return mcpNeedsReview(source, {
1401
+ node: anchor,
1402
+ note: applied.failure,
1403
+ reason: 'mcp-trailheads-invalid-edits',
1404
+ symbol: anchorSymbol,
1405
+ target,
1406
+ });
1407
+ }
1408
+ return applied;
1409
+ };
1410
+
1411
+ const applyMcpTrailheadsClass = (
1412
+ source: string,
1413
+ context: RegradeClassContext | undefined
1414
+ ): RegradeClassResult => {
1415
+ const skip = scanTargetSkip(context);
1416
+ if (skip !== null) {
1417
+ return skip;
1418
+ }
1419
+ if (!source.includes('railhead')) {
1420
+ return { kind: 'no-op', notes: ['No trailhead maps found.'] };
1421
+ }
1422
+ const path = context?.path ?? '<regrade-source>';
1423
+ const parsed = parseWithDiagnostics(path, source);
1424
+ if (!parsed.ast || parsed.diagnostics.length > 0) {
1425
+ return parseFailureResult(path, parsed.diagnostics);
1426
+ }
1427
+ const program = parsed.ast;
1428
+ const mapCandidates = collectTrailheadMapCandidates(program, source);
1429
+ const [first] = mapCandidates;
1430
+ if (first === undefined) {
1431
+ return { kind: 'no-op', notes: ['No trailhead maps found.'] };
1432
+ }
1433
+
1434
+ const merged = mergeTrailheadGroups(mapCandidates);
1435
+ if (!merged.ok) {
1436
+ return mcpNeedsReview(source, merged.review);
1437
+ }
1438
+
1439
+ const overlays = findOverlaysExport(program);
1440
+ if (overlays.declared) {
1441
+ return rewriteTrailheadsIntoOverlays({
1442
+ anchor: first.candidate.statement,
1443
+ anchorSymbol: first.candidate.name,
1444
+ groups: merged.groups,
1445
+ overlays,
1446
+ program,
1447
+ source,
1448
+ });
1449
+ }
1450
+
1451
+ if (moduleReferencesIdentifier(program, OVERLAYS_EXPORT_NAME)) {
1452
+ return {
1453
+ kind: 'no-op',
1454
+ notes: [
1455
+ 'Module already threads trailsOverlays next to the trailhead map; the call-site map stays as the runtime override-in-context.',
1456
+ ],
1457
+ };
1458
+ }
1459
+
1460
+ const target = mcpTargetSummary(merged.groups);
1461
+ return mcpNeedsReview(source, {
1462
+ node: first.candidate.statement,
1463
+ note: `Trailhead map lives outside the app module; ${target}.`,
1464
+ reason: 'mcp-trailheads-module-overlay-missing',
1465
+ symbol: first.candidate.name,
1466
+ target,
1467
+ });
1468
+ };
1469
+
1470
+ /**
1471
+ * Convert call-site MCP trailhead maps into `surfaceOverlay({ mcp: { name:
1472
+ * [selectors] } })` group bindings. Because the map usually lives in a
1473
+ * different file than the app module, the default outcome is a classified
1474
+ * `needs-review` handoff naming the exact target shape; when the same file
1475
+ * already exports `trailsOverlays`, the bindings are merged in place and the
1476
+ * call-site map is kept as the richer-metadata runtime override.
1477
+ *
1478
+ * @example
1479
+ * ```ts
1480
+ * import { mcpTrailheadsExportRestructureClass } from '@ontrails/regrade';
1481
+ *
1482
+ * const result = mcpTrailheadsExportRestructureClass.apply(
1483
+ * "export const trailheads = { search: { description: 'Search.', trails: ['search.query'] } };",
1484
+ * { path: 'apps/example/src/mcp-options.ts' }
1485
+ * );
1486
+ * // result.kind === 'needs-review'; the review detail names
1487
+ * // surfaceOverlay({ mcp: { search: ['search.query'] } }) as the target.
1488
+ * ```
1489
+ */
1490
+ export const mcpTrailheadsExportRestructureClass: RegradeClass = {
1491
+ apply: applyMcpTrailheadsClass,
1492
+ describe:
1493
+ 'Project call-site MCP trailhead maps into surfaceOverlay({ mcp }) group bindings inside trailsOverlays (classified handoff).',
1494
+ id: MCP_TRAILHEADS_CLASS_ID,
1495
+ };
1496
+
1497
+ /**
1498
+ * The export-restructure Regrade class family (TRL-1210), in deterministic
1499
+ * id order.
1500
+ *
1501
+ * @example
1502
+ * ```ts
1503
+ * import { exportRestructureClasses } from '@ontrails/regrade';
1504
+ *
1505
+ * exportRestructureClasses.map((cls) => cls.id);
1506
+ * // => ['export-restructure:cli-aliases', 'export-restructure:mcp-trailheads']
1507
+ * ```
1508
+ */
1509
+ export const exportRestructureClasses: readonly RegradeClass[] = Object.freeze([
1510
+ cliAliasesExportRestructureClass,
1511
+ mcpTrailheadsExportRestructureClass,
1512
+ ]);
1513
+
1514
+ /**
1515
+ * Convert a Warden rule that advertises the `export-restructure` fix class
1516
+ * into its registered Regrade class. Warden owns detection and fix metadata;
1517
+ * Regrade owns the structural transform. Returns `null` for rules without an
1518
+ * `export-restructure` fix class or without a registered transform.
1519
+ *
1520
+ * @example
1521
+ * ```ts
1522
+ * import { createWardenExportRestructureClass } from '@ontrails/regrade';
1523
+ * import { wardenRules } from '@ontrails/warden';
1524
+ *
1525
+ * const rule = wardenRules.get('no-legacy-cli-alias-export');
1526
+ * const cls = rule ? createWardenExportRestructureClass(rule) : null;
1527
+ * // cls?.id === 'export-restructure:cli-aliases'
1528
+ * ```
1529
+ */
1530
+ export const createWardenExportRestructureClass = (
1531
+ rule: WardenRule
1532
+ ): RegradeClass | null => {
1533
+ const metadata = getWardenRuleMetadata(rule);
1534
+ if (metadata?.fix?.class !== EXPORT_RESTRUCTURE_FIX_CLASS) {
1535
+ return null;
1536
+ }
1537
+ if (rule.name === 'no-legacy-cli-alias-export') {
1538
+ return cliAliasesExportRestructureClass;
1539
+ }
1540
+ return null;
1541
+ };
1542
+
1543
+ /**
1544
+ * Load every Warden-routed Regrade class: built-in and project-local
1545
+ * term-rewrite classes plus the export-restructure family. This is the
1546
+ * broader successor to {@link loadWardenTermRewriteClasses}, which stays
1547
+ * exported for callers that want span-rewrite classes only.
1548
+ *
1549
+ * @example
1550
+ * ```ts
1551
+ * import { loadWardenRegradeClasses } from '@ontrails/regrade';
1552
+ *
1553
+ * const { classes } = await loadWardenRegradeClasses(process.cwd());
1554
+ * classes.some((cls) => cls.id === 'export-restructure:cli-aliases');
1555
+ * // => true
1556
+ * ```
1557
+ */
1558
+ export const loadWardenRegradeClasses = async (
1559
+ root?: string
1560
+ ): Promise<RegradeWardenClassSet> => {
1561
+ const termRewrite = await loadWardenTermRewriteClasses(root);
1562
+
1563
+ const restructureRules = [...wardenRules.values()];
1564
+ if (root !== undefined) {
1565
+ // Project diagnostics are already carried by the term-rewrite loader; this
1566
+ // second pass only derives export-restructure-capable project rules.
1567
+ const projectRules = await loadProjectWardenRules(root);
1568
+ restructureRules.push(...projectRules.sourceRules);
1569
+ }
1570
+ const wardenRestructureClasses = restructureRules.flatMap((rule) => {
1571
+ const cls = createWardenExportRestructureClass(rule);
1572
+ return cls === null ? [] : [cls];
1573
+ });
1574
+
1575
+ const classes = [...termRewrite.classes];
1576
+ const seen = new Set(classes.map((cls) => cls.id));
1577
+ for (const cls of [
1578
+ ...wardenRestructureClasses,
1579
+ ...exportRestructureClasses,
1580
+ ]) {
1581
+ if (seen.has(cls.id)) {
1582
+ continue;
1583
+ }
1584
+ seen.add(cls.id);
1585
+ classes.push(cls);
1586
+ }
1587
+ return { classes, diagnostics: termRewrite.diagnostics };
1588
+ };