@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.
- package/CHANGELOG.md +606 -0
- package/package.json +40 -0
- package/src/downstream/ast-rewrite.ts +1031 -0
- package/src/downstream/collect.ts +240 -0
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/file-renames.ts +1677 -0
- package/src/downstream/package-source-artifact.ts +375 -0
- package/src/downstream/package-source-files.ts +195 -0
- package/src/downstream/package-source-manifest.ts +369 -0
- package/src/downstream/package-source.ts +237 -0
- package/src/downstream/report.ts +2085 -0
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +195 -0
- package/src/downstream/vocabulary.ts +3094 -0
- package/src/history-receipt.ts +847 -0
- package/src/index.ts +170 -0
- package/src/literal-transform.ts +124 -0
|
@@ -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
|
+
};
|