@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,1031 @@
|
|
|
1
|
+
import type { AstNode, AstScopeContext, SourceEdit } from '@ontrails/source';
|
|
2
|
+
import {
|
|
3
|
+
applySourceEdits,
|
|
4
|
+
createSourceEdit,
|
|
5
|
+
extractPlainTemplateLiteral,
|
|
6
|
+
getNodeCallee,
|
|
7
|
+
getStringValue,
|
|
8
|
+
identifierName,
|
|
9
|
+
isMemberExpression,
|
|
10
|
+
isStringLiteral,
|
|
11
|
+
offsetToLineColumn,
|
|
12
|
+
parseWithDiagnostics,
|
|
13
|
+
validateSourceEdits,
|
|
14
|
+
walkWithScopeContext,
|
|
15
|
+
} from '@ontrails/source';
|
|
16
|
+
import type { GovernedVocabularyTransition } from '@ontrails/warden';
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
RegradeClass,
|
|
20
|
+
RegradeClassContext,
|
|
21
|
+
RegradeClassResult,
|
|
22
|
+
RegradeReviewDetail,
|
|
23
|
+
} from './report.js';
|
|
24
|
+
|
|
25
|
+
export interface AstRewriteContext
|
|
26
|
+
extends AstScopeContext, RegradeClassContext {
|
|
27
|
+
readonly source: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type AstRewriteMatch =
|
|
31
|
+
| {
|
|
32
|
+
readonly edit: SourceEdit;
|
|
33
|
+
readonly kind: 'edit';
|
|
34
|
+
readonly note?: string;
|
|
35
|
+
}
|
|
36
|
+
| {
|
|
37
|
+
readonly detail?: RegradeReviewDetail;
|
|
38
|
+
readonly kind: 'review';
|
|
39
|
+
readonly note?: string;
|
|
40
|
+
readonly reason: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type AstRewriteVisitResult =
|
|
44
|
+
| AstRewriteMatch
|
|
45
|
+
| AstRewriteMatch[]
|
|
46
|
+
| null
|
|
47
|
+
| undefined;
|
|
48
|
+
|
|
49
|
+
export interface AstRewriteClassOptions {
|
|
50
|
+
readonly describe: string;
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly shouldScan?: (context: RegradeClassContext) => boolean;
|
|
53
|
+
readonly visit: (
|
|
54
|
+
node: AstNode,
|
|
55
|
+
context: AstRewriteContext
|
|
56
|
+
) => AstRewriteVisitResult;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const defaultRegradeClassContext: RegradeClassContext = {
|
|
60
|
+
path: '<regrade-source>',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const toArray = (result: AstRewriteVisitResult): readonly AstRewriteMatch[] => {
|
|
64
|
+
if (result === undefined || result === null) {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return Array.isArray(result) ? result : [result];
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const dedupeEdits = (edits: readonly SourceEdit[]): readonly SourceEdit[] => {
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
const unique: SourceEdit[] = [];
|
|
73
|
+
|
|
74
|
+
for (const edit of edits) {
|
|
75
|
+
const key = `${edit.start}:${edit.end}:${edit.replacement}`;
|
|
76
|
+
if (seen.has(key)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
seen.add(key);
|
|
80
|
+
unique.push(edit);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return unique;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const notesFor = (matches: readonly AstRewriteMatch[]): readonly string[] =>
|
|
87
|
+
matches.flatMap((match) => {
|
|
88
|
+
if (match.note) {
|
|
89
|
+
return [match.note];
|
|
90
|
+
}
|
|
91
|
+
if (match.kind === 'review') {
|
|
92
|
+
return [match.reason];
|
|
93
|
+
}
|
|
94
|
+
return [];
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const invalidEditsResult = (error: unknown): RegradeClassResult => ({
|
|
98
|
+
kind: 'needs-review',
|
|
99
|
+
notes: [
|
|
100
|
+
error instanceof Error
|
|
101
|
+
? `AST rewrite edits could not be applied: ${error.message}`
|
|
102
|
+
: 'AST rewrite edits could not be applied.',
|
|
103
|
+
],
|
|
104
|
+
reason: 'ast-rewrite-invalid-edits',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const astRewriteFailureResult = (
|
|
108
|
+
context: RegradeClassContext,
|
|
109
|
+
reason: string,
|
|
110
|
+
error: unknown
|
|
111
|
+
): RegradeClassResult => ({
|
|
112
|
+
kind: 'needs-review',
|
|
113
|
+
notes: [
|
|
114
|
+
error instanceof Error
|
|
115
|
+
? `AST rewrite failed for ${context.path}: ${error.message}`
|
|
116
|
+
: `AST rewrite failed for ${context.path}.`,
|
|
117
|
+
],
|
|
118
|
+
reason,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const reviewDetailsFor = (
|
|
122
|
+
classId: string,
|
|
123
|
+
matches: readonly AstRewriteMatch[]
|
|
124
|
+
): readonly RegradeReviewDetail[] | undefined => {
|
|
125
|
+
const details = matches.flatMap((match) => {
|
|
126
|
+
if (match.kind !== 'review' || match.detail === undefined) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
return [{ ...match.detail, classId: match.detail.classId ?? classId }];
|
|
130
|
+
});
|
|
131
|
+
return details.length === 0 ? undefined : details;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const createAstRewriteClass = (
|
|
135
|
+
options: AstRewriteClassOptions
|
|
136
|
+
): RegradeClass => ({
|
|
137
|
+
apply: (
|
|
138
|
+
source: string,
|
|
139
|
+
context: RegradeClassContext = defaultRegradeClassContext
|
|
140
|
+
): RegradeClassResult => {
|
|
141
|
+
if (options.shouldScan) {
|
|
142
|
+
try {
|
|
143
|
+
if (!options.shouldScan(context)) {
|
|
144
|
+
return {
|
|
145
|
+
kind: 'skipped',
|
|
146
|
+
notes: ['Skipped by AST rewrite scan-target filtering.'],
|
|
147
|
+
reason: 'ast-rewrite-scan-target-filtered',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch (error: unknown) {
|
|
151
|
+
return astRewriteFailureResult(
|
|
152
|
+
context,
|
|
153
|
+
'ast-rewrite-scan-target-failed',
|
|
154
|
+
error
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const parsed = parseWithDiagnostics(context.path, source);
|
|
160
|
+
if (!parsed.ast || parsed.diagnostics.length > 0) {
|
|
161
|
+
return {
|
|
162
|
+
kind: 'needs-review',
|
|
163
|
+
notes:
|
|
164
|
+
parsed.diagnostics.length > 0
|
|
165
|
+
? parsed.diagnostics.map(
|
|
166
|
+
(diagnostic) =>
|
|
167
|
+
`Could not safely parse ${context.path}: ${diagnostic.message}`
|
|
168
|
+
)
|
|
169
|
+
: [`Could not parse ${context.path} for AST rewrite.`],
|
|
170
|
+
reason: 'ast-rewrite-parse-failed',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const matches: AstRewriteMatch[] = [];
|
|
175
|
+
try {
|
|
176
|
+
walkWithScopeContext(parsed.ast, (node, scopeContext) => {
|
|
177
|
+
matches.push(
|
|
178
|
+
...toArray(
|
|
179
|
+
options.visit(node, {
|
|
180
|
+
...scopeContext,
|
|
181
|
+
...context,
|
|
182
|
+
source,
|
|
183
|
+
})
|
|
184
|
+
)
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
} catch (error: unknown) {
|
|
188
|
+
return astRewriteFailureResult(
|
|
189
|
+
context,
|
|
190
|
+
'ast-rewrite-visitor-failed',
|
|
191
|
+
error
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const reviewMatches = matches.filter((match) => match.kind === 'review');
|
|
196
|
+
if (reviewMatches.length > 0) {
|
|
197
|
+
const reviewDetails = reviewDetailsFor(options.id, reviewMatches);
|
|
198
|
+
return {
|
|
199
|
+
kind: 'needs-review',
|
|
200
|
+
notes: notesFor(reviewMatches),
|
|
201
|
+
reason: reviewMatches[0]?.reason ?? 'ast-rewrite-review-required',
|
|
202
|
+
...(reviewDetails === undefined ? {} : { reviewDetails }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const edits = dedupeEdits(
|
|
207
|
+
matches.flatMap((match) => (match.kind === 'edit' ? [match.edit] : []))
|
|
208
|
+
);
|
|
209
|
+
if (edits.length === 0) {
|
|
210
|
+
return {
|
|
211
|
+
kind: 'no-op',
|
|
212
|
+
notes: ['No AST rewrite matches found.'],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
validateSourceEdits(edits);
|
|
218
|
+
return {
|
|
219
|
+
kind: 'rewrite',
|
|
220
|
+
nextSource: applySourceEdits(source, edits),
|
|
221
|
+
notes: notesFor(matches),
|
|
222
|
+
};
|
|
223
|
+
} catch (error) {
|
|
224
|
+
return invalidEditsResult(error);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
describe: options.describe,
|
|
228
|
+
id: options.id,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
export interface AstIdentifierRenameClassOptions {
|
|
232
|
+
readonly describe?: string;
|
|
233
|
+
readonly from: string;
|
|
234
|
+
readonly id?: string;
|
|
235
|
+
readonly match?: AstIdentifierRenameMatchMode;
|
|
236
|
+
/** Route every matching identifier to review instead of producing an edit. */
|
|
237
|
+
readonly reviewAllMatches?: boolean;
|
|
238
|
+
readonly reviewDeclarationTypes?: ReadonlySet<string>;
|
|
239
|
+
readonly reviewExistingTargetSegments?: readonly string[];
|
|
240
|
+
readonly shouldPreserve?: (
|
|
241
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
242
|
+
) => boolean;
|
|
243
|
+
readonly to: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type AstIdentifierRenameMatchMode = 'exact' | 'identifier-segment';
|
|
247
|
+
|
|
248
|
+
export interface AstIdentifierRenameOccurrence {
|
|
249
|
+
readonly end: number;
|
|
250
|
+
readonly from: string;
|
|
251
|
+
readonly path: string;
|
|
252
|
+
readonly source: string;
|
|
253
|
+
readonly start: number;
|
|
254
|
+
readonly to: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface AstStringLiteralRenameClassOptions {
|
|
258
|
+
readonly allowModuleSpecifier?: boolean;
|
|
259
|
+
readonly describe?: string;
|
|
260
|
+
readonly from: string;
|
|
261
|
+
readonly id?: string;
|
|
262
|
+
readonly match?: 'exact' | 'property-key' | 'review';
|
|
263
|
+
readonly moduleSpecifierOnly?: boolean;
|
|
264
|
+
readonly shouldPreserve?: (
|
|
265
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
266
|
+
) => boolean;
|
|
267
|
+
/** Require the owning manifest to already admit this package route. */
|
|
268
|
+
readonly targetPackage?: string;
|
|
269
|
+
readonly to: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const isEsmModuleSpecifierPosition = (context: AstScopeContext): boolean =>
|
|
273
|
+
context.key === 'source' &&
|
|
274
|
+
(context.parent?.type === 'ImportDeclaration' ||
|
|
275
|
+
context.parent?.type === 'ExportAllDeclaration' ||
|
|
276
|
+
context.parent?.type === 'ExportNamedDeclaration' ||
|
|
277
|
+
context.parent?.type === 'ImportExpression' ||
|
|
278
|
+
context.parent?.type === 'TSImportType');
|
|
279
|
+
|
|
280
|
+
const isTypeScriptModuleSpecifierPosition = (
|
|
281
|
+
context: AstScopeContext
|
|
282
|
+
): boolean =>
|
|
283
|
+
(context.key === 'id' && context.parent?.type === 'TSModuleDeclaration') ||
|
|
284
|
+
(context.key === 'expression' &&
|
|
285
|
+
context.parent?.type === 'TSExternalModuleReference');
|
|
286
|
+
|
|
287
|
+
const memberAccessPath = (node: AstNode | undefined): string | null => {
|
|
288
|
+
const name = identifierName(node);
|
|
289
|
+
if (name !== null) {
|
|
290
|
+
return name;
|
|
291
|
+
}
|
|
292
|
+
if (!isMemberExpression(node)) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const object = memberAccessPath(node.object);
|
|
296
|
+
const property = identifierName(node.property);
|
|
297
|
+
return object === null || property === null ? null : `${object}.${property}`;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const MODULE_LOADER_CALLEES = new Set([
|
|
301
|
+
'Bun.mock.module',
|
|
302
|
+
'jest.createMockFromModule',
|
|
303
|
+
'jest.doMock',
|
|
304
|
+
'jest.genMockFromModule',
|
|
305
|
+
'jest.mock',
|
|
306
|
+
'jest.requireActual',
|
|
307
|
+
'jest.requireMock',
|
|
308
|
+
'jest.unmock',
|
|
309
|
+
'mock.module',
|
|
310
|
+
'require',
|
|
311
|
+
'require.resolve',
|
|
312
|
+
'vi.doMock',
|
|
313
|
+
'vi.doUnmock',
|
|
314
|
+
'vi.importActual',
|
|
315
|
+
'vi.importMock',
|
|
316
|
+
'vi.mock',
|
|
317
|
+
'vi.unmock',
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
const isLoaderModuleSpecifierPosition = (context: AstScopeContext): boolean => {
|
|
321
|
+
if (
|
|
322
|
+
context.key !== 'arguments' ||
|
|
323
|
+
context.parent?.type !== 'CallExpression'
|
|
324
|
+
) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const callee = memberAccessPath(getNodeCallee(context.parent));
|
|
329
|
+
return callee !== null && MODULE_LOADER_CALLEES.has(callee);
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const isModuleSpecifierPosition = (context: AstScopeContext): boolean =>
|
|
333
|
+
isEsmModuleSpecifierPosition(context) ||
|
|
334
|
+
isTypeScriptModuleSpecifierPosition(context) ||
|
|
335
|
+
isLoaderModuleSpecifierPosition(context);
|
|
336
|
+
|
|
337
|
+
const isTargetPackageMissing = (
|
|
338
|
+
context: AstRewriteContext,
|
|
339
|
+
targetPackage: string | undefined
|
|
340
|
+
): boolean => {
|
|
341
|
+
if (targetPackage === undefined || context.package?.name === targetPackage) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
const testSource =
|
|
345
|
+
context.path.split('/').includes('__tests__') ||
|
|
346
|
+
/(?:^|\.)(?:test|spec)\.[cm]?[jt]sx?$/.test(
|
|
347
|
+
context.path.split('/').at(-1) ?? ''
|
|
348
|
+
);
|
|
349
|
+
const dependencies = testSource
|
|
350
|
+
? context.package?.dependencies
|
|
351
|
+
: (context.package?.runtimeDependencies ?? context.package?.dependencies);
|
|
352
|
+
return !dependencies?.includes(targetPackage);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const isIdentifierNamed = (node: AstNode, name: string): boolean =>
|
|
356
|
+
node.type === 'Identifier' && identifierName(node) === name;
|
|
357
|
+
|
|
358
|
+
const stringLiteralNeedsReview = (
|
|
359
|
+
match: AstStringLiteralRenameClassOptions['match'],
|
|
360
|
+
contextKey: AstScopeContext['key']
|
|
361
|
+
): boolean =>
|
|
362
|
+
match === 'review' || (match === 'property-key' && contextKey !== 'key');
|
|
363
|
+
|
|
364
|
+
const identifierTokenSpan = (
|
|
365
|
+
node: AstNode,
|
|
366
|
+
source: string,
|
|
367
|
+
name: string
|
|
368
|
+
): { readonly end: number; readonly start: number } | null => {
|
|
369
|
+
const end = node.start + name.length;
|
|
370
|
+
return source.slice(node.start, end) === name
|
|
371
|
+
? { end, start: node.start }
|
|
372
|
+
: null;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const toPascalIdentifierSegment = (value: string): string =>
|
|
376
|
+
value.length === 0
|
|
377
|
+
? value
|
|
378
|
+
: `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`;
|
|
379
|
+
|
|
380
|
+
const isScreamingSnakeIdentifier = (name: string): boolean =>
|
|
381
|
+
/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(name);
|
|
382
|
+
|
|
383
|
+
const replaceScreamingSnakeIdentifierSegment = (
|
|
384
|
+
name: string,
|
|
385
|
+
from: string,
|
|
386
|
+
to: string
|
|
387
|
+
): string | null => {
|
|
388
|
+
if (!isScreamingSnakeIdentifier(name)) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const fromSegment = from.toUpperCase();
|
|
393
|
+
const toSegment = to.toUpperCase();
|
|
394
|
+
let changed = false;
|
|
395
|
+
const nextName = name
|
|
396
|
+
.split('_')
|
|
397
|
+
.map((segment) => {
|
|
398
|
+
if (segment !== fromSegment) {
|
|
399
|
+
return segment;
|
|
400
|
+
}
|
|
401
|
+
changed = true;
|
|
402
|
+
return toSegment;
|
|
403
|
+
})
|
|
404
|
+
.join('_');
|
|
405
|
+
|
|
406
|
+
return changed ? nextName : null;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const replaceCamelOrPascalIdentifierSegment = (
|
|
410
|
+
name: string,
|
|
411
|
+
from: string,
|
|
412
|
+
to: string
|
|
413
|
+
): string | null => {
|
|
414
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const fromPascalSegment = toPascalIdentifierSegment(from);
|
|
419
|
+
const toPascalSegment = toPascalIdentifierSegment(to);
|
|
420
|
+
const segmentPattern = /[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[0-9]+/g;
|
|
421
|
+
let cursor = 0;
|
|
422
|
+
let changed = false;
|
|
423
|
+
let nextName = '';
|
|
424
|
+
|
|
425
|
+
for (const match of name.matchAll(segmentPattern)) {
|
|
426
|
+
const [segment] = match;
|
|
427
|
+
const { index } = match;
|
|
428
|
+
if (index === undefined || index !== cursor) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (segment === from) {
|
|
433
|
+
changed = true;
|
|
434
|
+
nextName += to;
|
|
435
|
+
} else if (segment === fromPascalSegment) {
|
|
436
|
+
changed = true;
|
|
437
|
+
nextName += toPascalSegment;
|
|
438
|
+
} else {
|
|
439
|
+
nextName += segment;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
cursor = index + segment.length;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (cursor !== name.length || !changed) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return nextName;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const deriveIdentifierSegmentTarget = (
|
|
453
|
+
name: string,
|
|
454
|
+
from: string,
|
|
455
|
+
to: string
|
|
456
|
+
): string | null => {
|
|
457
|
+
const leadingUnderscores = /^_+/.exec(name)?.[0] ?? '';
|
|
458
|
+
const coreName =
|
|
459
|
+
leadingUnderscores.length > 0
|
|
460
|
+
? name.slice(leadingUnderscores.length)
|
|
461
|
+
: name;
|
|
462
|
+
if (coreName.length === 0) {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const screamingSnakeTarget = replaceScreamingSnakeIdentifierSegment(
|
|
467
|
+
coreName,
|
|
468
|
+
from,
|
|
469
|
+
to
|
|
470
|
+
);
|
|
471
|
+
if (screamingSnakeTarget !== null) {
|
|
472
|
+
return `${leadingUnderscores}${screamingSnakeTarget}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const camelOrPascalTarget = replaceCamelOrPascalIdentifierSegment(
|
|
476
|
+
coreName,
|
|
477
|
+
from,
|
|
478
|
+
to
|
|
479
|
+
);
|
|
480
|
+
return camelOrPascalTarget === null
|
|
481
|
+
? null
|
|
482
|
+
: `${leadingUnderscores}${camelOrPascalTarget}`;
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
const hasIdentifierSegment = (name: string, segment: string): boolean =>
|
|
486
|
+
deriveIdentifierSegmentTarget(name, segment, segment) !== null;
|
|
487
|
+
|
|
488
|
+
interface AstIdentifierRenameMatch {
|
|
489
|
+
readonly from: string;
|
|
490
|
+
readonly span: { readonly end: number; readonly start: number } | null;
|
|
491
|
+
readonly to: string;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const resolveIdentifierRenameMatch = (
|
|
495
|
+
node: AstNode,
|
|
496
|
+
source: string,
|
|
497
|
+
options: {
|
|
498
|
+
readonly from: string;
|
|
499
|
+
readonly match: AstIdentifierRenameMatchMode;
|
|
500
|
+
readonly to: string;
|
|
501
|
+
}
|
|
502
|
+
): AstIdentifierRenameMatch | null => {
|
|
503
|
+
if (options.match === 'exact') {
|
|
504
|
+
if (!isIdentifierNamed(node, options.from)) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
from: options.from,
|
|
510
|
+
span: identifierTokenSpan(node, source, options.from),
|
|
511
|
+
to: options.to,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (node.type !== 'Identifier') {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const name = identifierName(node);
|
|
520
|
+
if (name === null) {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
const target = deriveIdentifierSegmentTarget(name, options.from, options.to);
|
|
524
|
+
if (target === null) {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
from: name,
|
|
530
|
+
span: identifierTokenSpan(node, source, name),
|
|
531
|
+
to: target,
|
|
532
|
+
};
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
export const createAstIdentifierRenameClass = (
|
|
536
|
+
options: AstIdentifierRenameClassOptions
|
|
537
|
+
): RegradeClass => {
|
|
538
|
+
const matchMode = options.match ?? 'exact';
|
|
539
|
+
const reviewDeclarationTypes =
|
|
540
|
+
options.reviewDeclarationTypes ?? new Set<string>();
|
|
541
|
+
|
|
542
|
+
return createAstRewriteClass({
|
|
543
|
+
describe:
|
|
544
|
+
options.describe ??
|
|
545
|
+
`Rename identifier "${options.from}" to "${options.to}".`,
|
|
546
|
+
id: options.id ?? `ast-identifier-rename:${options.from}->${options.to}`,
|
|
547
|
+
visit: (node, context) => {
|
|
548
|
+
const match = resolveIdentifierRenameMatch(node, context.source, {
|
|
549
|
+
from: options.from,
|
|
550
|
+
match: matchMode,
|
|
551
|
+
to: options.to,
|
|
552
|
+
});
|
|
553
|
+
if (match === null) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const { span } = match;
|
|
558
|
+
if (span === null) {
|
|
559
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
560
|
+
const caution = `Identifier "${match.from}" token span could not be verified; routed to review.`;
|
|
561
|
+
return {
|
|
562
|
+
detail: {
|
|
563
|
+
candidateReplacement: match.to,
|
|
564
|
+
expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
|
|
565
|
+
judgment: 'unresolved',
|
|
566
|
+
matchedForm: match.from,
|
|
567
|
+
nodeKind: node.type,
|
|
568
|
+
preserveCautions: [caution],
|
|
569
|
+
reason: 'ast-identifier-token-span-unverified',
|
|
570
|
+
signals: ['ast:identifier-rename'],
|
|
571
|
+
span: {
|
|
572
|
+
column: location.column,
|
|
573
|
+
end: node.end,
|
|
574
|
+
line: location.line,
|
|
575
|
+
start: node.start,
|
|
576
|
+
},
|
|
577
|
+
suggestedValidation: 'bun run typecheck',
|
|
578
|
+
symbol: match.from,
|
|
579
|
+
},
|
|
580
|
+
kind: 'review',
|
|
581
|
+
note: caution,
|
|
582
|
+
reason: 'ast-identifier-token-span-unverified',
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (options.reviewAllMatches === true) {
|
|
587
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
588
|
+
const caution = `Identifier "${match.from}" is a derived naming candidate; routed to review.`;
|
|
589
|
+
return {
|
|
590
|
+
detail: {
|
|
591
|
+
candidateReplacement: match.to,
|
|
592
|
+
expectedTarget: `Review identifier "${match.from}" before renaming it to "${match.to}".`,
|
|
593
|
+
judgment: 'unresolved',
|
|
594
|
+
matchedForm: match.from,
|
|
595
|
+
nodeKind: node.type,
|
|
596
|
+
preserveCautions: [caution],
|
|
597
|
+
reason: 'ast-identifier-plan-review',
|
|
598
|
+
signals: ['ast:identifier-rename'],
|
|
599
|
+
span: {
|
|
600
|
+
column: location.column,
|
|
601
|
+
end: span.end,
|
|
602
|
+
line: location.line,
|
|
603
|
+
start: span.start,
|
|
604
|
+
},
|
|
605
|
+
suggestedValidation: 'bun run typecheck',
|
|
606
|
+
symbol: match.from,
|
|
607
|
+
},
|
|
608
|
+
kind: 'review',
|
|
609
|
+
note: caution,
|
|
610
|
+
reason: 'ast-identifier-plan-review',
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (
|
|
615
|
+
options.shouldPreserve?.({
|
|
616
|
+
end: span.end,
|
|
617
|
+
from: match.from,
|
|
618
|
+
path: context.path,
|
|
619
|
+
source: context.source,
|
|
620
|
+
start: span.start,
|
|
621
|
+
to: match.to,
|
|
622
|
+
}) === true
|
|
623
|
+
) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const existingTargetSegment = (
|
|
628
|
+
options.reviewExistingTargetSegments ?? [options.to]
|
|
629
|
+
).find((segment) => hasIdentifierSegment(match.from, segment));
|
|
630
|
+
if (
|
|
631
|
+
matchMode === 'identifier-segment' &&
|
|
632
|
+
existingTargetSegment !== undefined
|
|
633
|
+
) {
|
|
634
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
635
|
+
const caution = `Identifier "${match.from}" already contains target segment "${existingTargetSegment}"; routed to review.`;
|
|
636
|
+
return {
|
|
637
|
+
detail: {
|
|
638
|
+
candidateReplacement: match.to,
|
|
639
|
+
expectedTarget: `Review identifier "${match.from}" before replacing "${options.from}" with "${options.to}".`,
|
|
640
|
+
judgment: 'unresolved',
|
|
641
|
+
matchedForm: match.from,
|
|
642
|
+
nodeKind: node.type,
|
|
643
|
+
preserveCautions: [caution],
|
|
644
|
+
reason: 'ast-identifier-target-segment-present',
|
|
645
|
+
signals: ['ast:identifier-rename'],
|
|
646
|
+
span: {
|
|
647
|
+
column: location.column,
|
|
648
|
+
end: span.end,
|
|
649
|
+
line: location.line,
|
|
650
|
+
start: span.start,
|
|
651
|
+
},
|
|
652
|
+
suggestedValidation: 'bun run typecheck',
|
|
653
|
+
symbol: match.from,
|
|
654
|
+
},
|
|
655
|
+
kind: 'review',
|
|
656
|
+
note: caution,
|
|
657
|
+
reason: 'ast-identifier-target-segment-present',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const declaration = context.getDeclaration(match.from);
|
|
662
|
+
if (declaration && reviewDeclarationTypes.has(declaration.type)) {
|
|
663
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
664
|
+
const caution = `Identifier "${match.from}" resolves to ${declaration.type}; routed to review.`;
|
|
665
|
+
return {
|
|
666
|
+
detail: {
|
|
667
|
+
candidateReplacement: match.to,
|
|
668
|
+
expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
|
|
669
|
+
judgment: 'unresolved',
|
|
670
|
+
matchedForm: match.from,
|
|
671
|
+
nodeKind: node.type,
|
|
672
|
+
preserveCautions: [caution],
|
|
673
|
+
reason: 'ast-identifier-review-declaration',
|
|
674
|
+
signals: ['ast:identifier-rename'],
|
|
675
|
+
span: {
|
|
676
|
+
column: location.column,
|
|
677
|
+
end: span.end,
|
|
678
|
+
line: location.line,
|
|
679
|
+
start: span.start,
|
|
680
|
+
},
|
|
681
|
+
suggestedValidation: 'bun run typecheck',
|
|
682
|
+
symbol: match.from,
|
|
683
|
+
},
|
|
684
|
+
kind: 'review',
|
|
685
|
+
note: caution,
|
|
686
|
+
reason: 'ast-identifier-review-declaration',
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (
|
|
691
|
+
context.parent?.type === 'ImportSpecifier' ||
|
|
692
|
+
context.parent?.type === 'ImportDefaultSpecifier' ||
|
|
693
|
+
context.parent?.type === 'ImportNamespaceSpecifier' ||
|
|
694
|
+
context.parent?.type === 'ExportSpecifier'
|
|
695
|
+
) {
|
|
696
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
697
|
+
const caution = `Identifier "${match.from}" names an import or export boundary; routed to review.`;
|
|
698
|
+
return {
|
|
699
|
+
detail: {
|
|
700
|
+
candidateReplacement: match.to,
|
|
701
|
+
expectedTarget: `Review public or foreign identifier "${match.from}" before renaming it to "${match.to}".`,
|
|
702
|
+
judgment: 'unresolved',
|
|
703
|
+
matchedForm: match.from,
|
|
704
|
+
nodeKind: node.type,
|
|
705
|
+
preserveCautions: [caution],
|
|
706
|
+
reason: 'ast-identifier-module-boundary',
|
|
707
|
+
signals: ['ast:identifier-rename'],
|
|
708
|
+
span: {
|
|
709
|
+
column: location.column,
|
|
710
|
+
end: span.end,
|
|
711
|
+
line: location.line,
|
|
712
|
+
start: span.start,
|
|
713
|
+
},
|
|
714
|
+
suggestedValidation: 'bun run typecheck',
|
|
715
|
+
symbol: match.from,
|
|
716
|
+
},
|
|
717
|
+
kind: 'review',
|
|
718
|
+
note: caution,
|
|
719
|
+
reason: 'ast-identifier-module-boundary',
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
edit: createSourceEdit(span.start, span.end, match.to),
|
|
725
|
+
kind: 'edit',
|
|
726
|
+
note: `Renamed identifier "${match.from}" to "${match.to}".`,
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const stringLiteralValueSpan = (
|
|
733
|
+
node: AstNode,
|
|
734
|
+
source: string,
|
|
735
|
+
value: string
|
|
736
|
+
): { readonly end: number; readonly start: number } | null => {
|
|
737
|
+
const raw = source.slice(node.start, node.end);
|
|
738
|
+
const relativeStart = raw.indexOf(value);
|
|
739
|
+
if (relativeStart === -1 || raw.includes(value, relativeStart + 1)) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
const start = node.start + relativeStart;
|
|
743
|
+
return { end: start + value.length, start };
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const isAdjacentModuleRoute = (value: string, route: string): boolean =>
|
|
747
|
+
value.startsWith(`${route}/`) || value.startsWith(`${route}.`);
|
|
748
|
+
|
|
749
|
+
interface AstStringLiteralMatch {
|
|
750
|
+
readonly adjacentModuleRoute: boolean;
|
|
751
|
+
readonly value: string;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const matchAstStringLiteral = (
|
|
755
|
+
node: AstNode,
|
|
756
|
+
options: AstStringLiteralRenameClassOptions
|
|
757
|
+
): AstStringLiteralMatch | null => {
|
|
758
|
+
if (!isStringLiteral(node) && node.type !== 'TemplateLiteral') {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
const value = isStringLiteral(node)
|
|
762
|
+
? getStringValue(node)
|
|
763
|
+
: extractPlainTemplateLiteral(node);
|
|
764
|
+
if (value === null) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
const adjacentModuleRoute =
|
|
768
|
+
options.allowModuleSpecifier === true &&
|
|
769
|
+
isAdjacentModuleRoute(value, options.from);
|
|
770
|
+
return value === options.from || adjacentModuleRoute
|
|
771
|
+
? { adjacentModuleRoute, value }
|
|
772
|
+
: null;
|
|
773
|
+
};
|
|
774
|
+
|
|
775
|
+
export const createAstStringLiteralRenameClass = (
|
|
776
|
+
options: AstStringLiteralRenameClassOptions
|
|
777
|
+
): RegradeClass =>
|
|
778
|
+
createAstRewriteClass({
|
|
779
|
+
describe:
|
|
780
|
+
options.describe ??
|
|
781
|
+
`Rename string literal "${options.from}" to "${options.to}".`,
|
|
782
|
+
id:
|
|
783
|
+
options.id ?? `ast-string-literal-rename:${options.from}->${options.to}`,
|
|
784
|
+
visit: (node, context) => {
|
|
785
|
+
const match = matchAstStringLiteral(node, options);
|
|
786
|
+
if (match === null) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
if (
|
|
790
|
+
options.moduleSpecifierOnly === true &&
|
|
791
|
+
!isModuleSpecifierPosition(context)
|
|
792
|
+
) {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
const { adjacentModuleRoute, value } = match;
|
|
796
|
+
|
|
797
|
+
const span = stringLiteralValueSpan(node, context.source, value);
|
|
798
|
+
if (span === null) {
|
|
799
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
800
|
+
const caution = `String literal "${options.from}" token span could not be verified; routed to review.`;
|
|
801
|
+
return {
|
|
802
|
+
detail: {
|
|
803
|
+
candidateReplacement: options.to,
|
|
804
|
+
expectedTarget: `Rename string literal "${options.from}" to "${options.to}".`,
|
|
805
|
+
judgment: 'unresolved',
|
|
806
|
+
matchedForm: options.from,
|
|
807
|
+
nodeKind: node.type,
|
|
808
|
+
preserveCautions: [caution],
|
|
809
|
+
reason: 'ast-string-literal-token-span-unverified',
|
|
810
|
+
signals: ['ast:string-literal-rename'],
|
|
811
|
+
span: {
|
|
812
|
+
column: location.column,
|
|
813
|
+
end: node.end,
|
|
814
|
+
line: location.line,
|
|
815
|
+
start: node.start,
|
|
816
|
+
},
|
|
817
|
+
suggestedValidation: 'bun run typecheck',
|
|
818
|
+
symbol: options.from,
|
|
819
|
+
},
|
|
820
|
+
kind: 'review',
|
|
821
|
+
note: caution,
|
|
822
|
+
reason: 'ast-string-literal-token-span-unverified',
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (
|
|
827
|
+
options.shouldPreserve?.({
|
|
828
|
+
end: span.end,
|
|
829
|
+
from: value,
|
|
830
|
+
path: context.path,
|
|
831
|
+
source: context.source,
|
|
832
|
+
start: span.start,
|
|
833
|
+
to: options.to,
|
|
834
|
+
}) === true
|
|
835
|
+
) {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
if (adjacentModuleRoute) {
|
|
840
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
841
|
+
const caution = `Module route "${value}" is adjacent to governed route "${options.from}"; routed to review.`;
|
|
842
|
+
return {
|
|
843
|
+
detail: {
|
|
844
|
+
candidateReplacement: `${options.to}${value.slice(options.from.length)}`,
|
|
845
|
+
expectedTarget: `Review whether module route "${value}" moves with governed route "${options.from}".`,
|
|
846
|
+
judgment: 'unresolved',
|
|
847
|
+
matchedForm: value,
|
|
848
|
+
nodeKind: node.type,
|
|
849
|
+
preserveCautions: [caution],
|
|
850
|
+
reason: 'ast-string-literal-adjacent-module-route',
|
|
851
|
+
signals: ['ast:string-literal-rename', 'ast:module-specifier'],
|
|
852
|
+
span: {
|
|
853
|
+
column: location.column,
|
|
854
|
+
end: node.end,
|
|
855
|
+
line: location.line,
|
|
856
|
+
start: node.start,
|
|
857
|
+
},
|
|
858
|
+
suggestedValidation:
|
|
859
|
+
'Confirm whether the adjacent module route is public, private, or intentionally preserved.',
|
|
860
|
+
symbol: value,
|
|
861
|
+
},
|
|
862
|
+
kind: 'review',
|
|
863
|
+
note: caution,
|
|
864
|
+
reason: 'ast-string-literal-adjacent-module-route',
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (
|
|
869
|
+
options.allowModuleSpecifier !== true &&
|
|
870
|
+
isModuleSpecifierPosition(context)
|
|
871
|
+
) {
|
|
872
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
873
|
+
const caution = `String literal "${options.from}" is a module specifier; routed to review.`;
|
|
874
|
+
return {
|
|
875
|
+
detail: {
|
|
876
|
+
candidateReplacement: options.to,
|
|
877
|
+
expectedTarget: `Review module route "${options.from}" before renaming it to "${options.to}".`,
|
|
878
|
+
judgment: 'unresolved',
|
|
879
|
+
matchedForm: options.from,
|
|
880
|
+
nodeKind: node.type,
|
|
881
|
+
preserveCautions: [caution],
|
|
882
|
+
reason: 'ast-string-literal-module-specifier',
|
|
883
|
+
signals: ['ast:string-literal-rename', 'ast:module-specifier'],
|
|
884
|
+
span: {
|
|
885
|
+
column: location.column,
|
|
886
|
+
end: node.end,
|
|
887
|
+
line: location.line,
|
|
888
|
+
start: node.start,
|
|
889
|
+
},
|
|
890
|
+
suggestedValidation:
|
|
891
|
+
'Confirm the module route is owned by this transition before changing it.',
|
|
892
|
+
symbol: options.from,
|
|
893
|
+
},
|
|
894
|
+
kind: 'review',
|
|
895
|
+
note: caution,
|
|
896
|
+
reason: 'ast-string-literal-module-specifier',
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if (isTargetPackageMissing(context, options.targetPackage)) {
|
|
901
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
902
|
+
const manifest = context.package?.path ?? 'the owning package.json';
|
|
903
|
+
const invalidManifest = context.package?.manifestState === 'invalid';
|
|
904
|
+
const caution = invalidManifest
|
|
905
|
+
? `Package manifest ${manifest} is invalid; routed to review before rewriting package route "${options.to}".`
|
|
906
|
+
: `Package route "${options.to}" is not declared by ${manifest}; routed to review before rewriting source.`;
|
|
907
|
+
return {
|
|
908
|
+
detail: {
|
|
909
|
+
candidateReplacement: options.to,
|
|
910
|
+
expectedTarget: invalidManifest
|
|
911
|
+
? `Fix invalid package manifest ${manifest}, declare "${options.targetPackage}", install dependencies, then rename "${options.from}" to "${options.to}".`
|
|
912
|
+
: `Declare "${options.targetPackage}" in ${manifest}, install dependencies, then rename "${options.from}" to "${options.to}".`,
|
|
913
|
+
judgment: 'unresolved',
|
|
914
|
+
matchedForm: options.from,
|
|
915
|
+
nodeKind: node.type,
|
|
916
|
+
preserveCautions: [caution],
|
|
917
|
+
reason: 'package-route-target-dependency-unverified',
|
|
918
|
+
signals: [
|
|
919
|
+
'ast:string-literal-rename',
|
|
920
|
+
'package:dependency',
|
|
921
|
+
...(invalidManifest ? ['package:manifest-invalid'] : []),
|
|
922
|
+
],
|
|
923
|
+
span: {
|
|
924
|
+
column: location.column,
|
|
925
|
+
end: node.end,
|
|
926
|
+
line: location.line,
|
|
927
|
+
start: node.start,
|
|
928
|
+
},
|
|
929
|
+
suggestedValidation: invalidManifest
|
|
930
|
+
? 'Repair the package manifest, install dependencies, and run the package typecheck.'
|
|
931
|
+
: 'Install dependencies and run the package typecheck.',
|
|
932
|
+
symbol: options.from,
|
|
933
|
+
},
|
|
934
|
+
kind: 'review',
|
|
935
|
+
note: caution,
|
|
936
|
+
reason: 'package-route-target-dependency-unverified',
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if (stringLiteralNeedsReview(options.match, context.key)) {
|
|
941
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
942
|
+
const caution = `String literal "${options.from}" is not proven framework-owned; routed to review.`;
|
|
943
|
+
return {
|
|
944
|
+
detail: {
|
|
945
|
+
candidateReplacement: options.to,
|
|
946
|
+
expectedTarget: `Review whether string literal "${options.from}" is framework-owned before renaming it to "${options.to}".`,
|
|
947
|
+
judgment: 'unresolved',
|
|
948
|
+
matchedForm: options.from,
|
|
949
|
+
nodeKind: node.type,
|
|
950
|
+
preserveCautions: [caution],
|
|
951
|
+
reason: 'ast-string-literal-review-position',
|
|
952
|
+
signals: [
|
|
953
|
+
'ast:string-literal-rename',
|
|
954
|
+
'ast:framework-position-required',
|
|
955
|
+
],
|
|
956
|
+
span: {
|
|
957
|
+
column: location.column,
|
|
958
|
+
end: node.end,
|
|
959
|
+
line: location.line,
|
|
960
|
+
start: node.start,
|
|
961
|
+
},
|
|
962
|
+
suggestedValidation:
|
|
963
|
+
'Confirm the literal is owned by a Trails contract before changing it.',
|
|
964
|
+
symbol: options.from,
|
|
965
|
+
},
|
|
966
|
+
kind: 'review',
|
|
967
|
+
note: caution,
|
|
968
|
+
reason: 'ast-string-literal-review-position',
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return {
|
|
973
|
+
edit: createSourceEdit(span.start, span.end, options.to),
|
|
974
|
+
kind: 'edit',
|
|
975
|
+
note: `Renamed string literal "${options.from}" to "${options.to}".`,
|
|
976
|
+
};
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
export const createGovernedAstIdentifierRenameClasses = (
|
|
981
|
+
transition: GovernedVocabularyTransition,
|
|
982
|
+
options: {
|
|
983
|
+
readonly shouldPreserve?: (
|
|
984
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
985
|
+
) => boolean;
|
|
986
|
+
} = {}
|
|
987
|
+
): readonly RegradeClass[] => {
|
|
988
|
+
const targetSegments = [
|
|
989
|
+
...new Set(transition.symbolRenames.map((rename) => rename.to)),
|
|
990
|
+
];
|
|
991
|
+
const orderedLiteralRenames = [...transition.stringLiteralRenames].toSorted(
|
|
992
|
+
(left, right) =>
|
|
993
|
+
right.from.length - left.from.length ||
|
|
994
|
+
left.from.localeCompare(right.from)
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
return [
|
|
998
|
+
...transition.symbolRenames.map((rename) =>
|
|
999
|
+
createAstIdentifierRenameClass({
|
|
1000
|
+
describe: `Rename governed symbol "${rename.from}" to "${rename.to}" for ${transition.id}.`,
|
|
1001
|
+
from: rename.from,
|
|
1002
|
+
id: `ast-symbol-rename:${transition.id}:${rename.from}->${rename.to}`,
|
|
1003
|
+
match: rename.match,
|
|
1004
|
+
reviewDeclarationTypes: new Set(rename.reviewDeclarationTypes),
|
|
1005
|
+
reviewExistingTargetSegments: targetSegments,
|
|
1006
|
+
...(options.shouldPreserve === undefined
|
|
1007
|
+
? {}
|
|
1008
|
+
: { shouldPreserve: options.shouldPreserve }),
|
|
1009
|
+
to: rename.to,
|
|
1010
|
+
})
|
|
1011
|
+
),
|
|
1012
|
+
...orderedLiteralRenames.map((rename) =>
|
|
1013
|
+
createAstStringLiteralRenameClass({
|
|
1014
|
+
describe: `Rename governed string literal "${rename.from}" to "${rename.to}" for ${transition.id}.`,
|
|
1015
|
+
from: rename.from,
|
|
1016
|
+
id: `ast-string-literal-rename:${transition.id}:${rename.from}->${rename.to}`,
|
|
1017
|
+
...(rename.match === undefined ? {} : { match: rename.match }),
|
|
1018
|
+
...(options.shouldPreserve === undefined
|
|
1019
|
+
? {}
|
|
1020
|
+
: { shouldPreserve: options.shouldPreserve }),
|
|
1021
|
+
...(rename.moduleSpecifier === undefined
|
|
1022
|
+
? {}
|
|
1023
|
+
: {
|
|
1024
|
+
allowModuleSpecifier: true,
|
|
1025
|
+
targetPackage: rename.moduleSpecifier.targetPackage,
|
|
1026
|
+
}),
|
|
1027
|
+
to: rename.to,
|
|
1028
|
+
})
|
|
1029
|
+
),
|
|
1030
|
+
];
|
|
1031
|
+
};
|