@ontrails/regrade 1.0.0-beta.32 → 1.0.0-beta.41
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 +135 -0
- package/package.json +6 -5
- package/src/downstream/ast-rewrite.ts +725 -17
- package/src/downstream/collect.ts +17 -2
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/report.ts +409 -48
- package/src/downstream/vocabulary-registry.ts +96 -0
- package/src/downstream/vocabulary.ts +1160 -118
- package/src/index.ts +27 -0
- package/src/literal-transform.ts +26 -20
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AstNode,
|
|
3
|
-
AstScopeContext,
|
|
4
|
-
SourceEdit,
|
|
5
|
-
} from '@ontrails/warden/ast';
|
|
1
|
+
import type { AstNode, AstScopeContext, SourceEdit } from '@ontrails/source';
|
|
6
2
|
import {
|
|
7
3
|
applySourceEdits,
|
|
8
4
|
createSourceEdit,
|
|
5
|
+
extractPlainTemplateLiteral,
|
|
6
|
+
getNodeCallee,
|
|
7
|
+
getStringValue,
|
|
9
8
|
identifierName,
|
|
9
|
+
isMemberExpression,
|
|
10
|
+
isStringLiteral,
|
|
10
11
|
offsetToLineColumn,
|
|
11
12
|
parseWithDiagnostics,
|
|
12
13
|
validateSourceEdits,
|
|
13
14
|
walkWithScopeContext,
|
|
14
|
-
} from '@ontrails/
|
|
15
|
+
} from '@ontrails/source';
|
|
16
|
+
import type { GovernedVocabularyTransition } from '@ontrails/warden';
|
|
15
17
|
|
|
16
18
|
import type {
|
|
17
19
|
RegradeClass,
|
|
@@ -20,8 +22,8 @@ import type {
|
|
|
20
22
|
RegradeReviewDetail,
|
|
21
23
|
} from './report.js';
|
|
22
24
|
|
|
23
|
-
export interface AstRewriteContext
|
|
24
|
-
|
|
25
|
+
export interface AstRewriteContext
|
|
26
|
+
extends AstScopeContext, RegradeClassContext {
|
|
25
27
|
readonly source: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
@@ -176,7 +178,7 @@ export const createAstRewriteClass = (
|
|
|
176
178
|
...toArray(
|
|
177
179
|
options.visit(node, {
|
|
178
180
|
...scopeContext,
|
|
179
|
-
|
|
181
|
+
...context,
|
|
180
182
|
source,
|
|
181
183
|
})
|
|
182
184
|
)
|
|
@@ -230,16 +232,307 @@ export interface AstIdentifierRenameClassOptions {
|
|
|
230
232
|
readonly describe?: string;
|
|
231
233
|
readonly from: string;
|
|
232
234
|
readonly id?: string;
|
|
235
|
+
readonly match?: AstIdentifierRenameMatchMode;
|
|
233
236
|
readonly reviewDeclarationTypes?: ReadonlySet<string>;
|
|
237
|
+
readonly reviewExistingTargetSegments?: readonly string[];
|
|
238
|
+
readonly shouldPreserve?: (
|
|
239
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
240
|
+
) => boolean;
|
|
241
|
+
readonly to: string;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type AstIdentifierRenameMatchMode = 'exact' | 'identifier-segment';
|
|
245
|
+
|
|
246
|
+
export interface AstIdentifierRenameOccurrence {
|
|
247
|
+
readonly end: number;
|
|
248
|
+
readonly from: string;
|
|
249
|
+
readonly path: string;
|
|
250
|
+
readonly source: string;
|
|
251
|
+
readonly start: number;
|
|
252
|
+
readonly to: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface AstStringLiteralRenameClassOptions {
|
|
256
|
+
readonly allowModuleSpecifier?: boolean;
|
|
257
|
+
readonly describe?: string;
|
|
258
|
+
readonly from: string;
|
|
259
|
+
readonly id?: string;
|
|
260
|
+
readonly match?: 'exact' | 'property-key' | 'review';
|
|
261
|
+
readonly shouldPreserve?: (
|
|
262
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
263
|
+
) => boolean;
|
|
264
|
+
/** Require the owning manifest to already admit this package route. */
|
|
265
|
+
readonly targetPackage?: string;
|
|
234
266
|
readonly to: string;
|
|
235
267
|
}
|
|
236
268
|
|
|
269
|
+
const isEsmModuleSpecifierPosition = (context: AstScopeContext): boolean =>
|
|
270
|
+
context.key === 'source' &&
|
|
271
|
+
(context.parent?.type === 'ImportDeclaration' ||
|
|
272
|
+
context.parent?.type === 'ExportAllDeclaration' ||
|
|
273
|
+
context.parent?.type === 'ExportNamedDeclaration' ||
|
|
274
|
+
context.parent?.type === 'ImportExpression' ||
|
|
275
|
+
context.parent?.type === 'TSImportType');
|
|
276
|
+
|
|
277
|
+
const isTypeScriptModuleSpecifierPosition = (
|
|
278
|
+
context: AstScopeContext
|
|
279
|
+
): boolean =>
|
|
280
|
+
(context.key === 'id' && context.parent?.type === 'TSModuleDeclaration') ||
|
|
281
|
+
(context.key === 'expression' &&
|
|
282
|
+
context.parent?.type === 'TSExternalModuleReference');
|
|
283
|
+
|
|
284
|
+
const memberAccessPath = (node: AstNode | undefined): string | null => {
|
|
285
|
+
const name = identifierName(node);
|
|
286
|
+
if (name !== null) {
|
|
287
|
+
return name;
|
|
288
|
+
}
|
|
289
|
+
if (!isMemberExpression(node)) {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
const object = memberAccessPath(node.object);
|
|
293
|
+
const property = identifierName(node.property);
|
|
294
|
+
return object === null || property === null ? null : `${object}.${property}`;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const MODULE_LOADER_CALLEES = new Set([
|
|
298
|
+
'Bun.mock.module',
|
|
299
|
+
'jest.createMockFromModule',
|
|
300
|
+
'jest.doMock',
|
|
301
|
+
'jest.genMockFromModule',
|
|
302
|
+
'jest.mock',
|
|
303
|
+
'jest.requireActual',
|
|
304
|
+
'jest.requireMock',
|
|
305
|
+
'jest.unmock',
|
|
306
|
+
'mock.module',
|
|
307
|
+
'require',
|
|
308
|
+
'require.resolve',
|
|
309
|
+
'vi.doMock',
|
|
310
|
+
'vi.doUnmock',
|
|
311
|
+
'vi.importActual',
|
|
312
|
+
'vi.importMock',
|
|
313
|
+
'vi.mock',
|
|
314
|
+
'vi.unmock',
|
|
315
|
+
]);
|
|
316
|
+
|
|
317
|
+
const isLoaderModuleSpecifierPosition = (context: AstScopeContext): boolean => {
|
|
318
|
+
if (
|
|
319
|
+
context.key !== 'arguments' ||
|
|
320
|
+
context.parent?.type !== 'CallExpression'
|
|
321
|
+
) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const callee = memberAccessPath(getNodeCallee(context.parent));
|
|
326
|
+
return callee !== null && MODULE_LOADER_CALLEES.has(callee);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const isModuleSpecifierPosition = (context: AstScopeContext): boolean =>
|
|
330
|
+
isEsmModuleSpecifierPosition(context) ||
|
|
331
|
+
isTypeScriptModuleSpecifierPosition(context) ||
|
|
332
|
+
isLoaderModuleSpecifierPosition(context);
|
|
333
|
+
|
|
334
|
+
const isTargetPackageMissing = (
|
|
335
|
+
context: AstRewriteContext,
|
|
336
|
+
targetPackage: string | undefined
|
|
337
|
+
): boolean => {
|
|
338
|
+
if (targetPackage === undefined || context.package?.name === targetPackage) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
const testSource =
|
|
342
|
+
context.path.split('/').includes('__tests__') ||
|
|
343
|
+
/(?:^|\.)(?:test|spec)\.[cm]?[jt]sx?$/.test(
|
|
344
|
+
context.path.split('/').at(-1) ?? ''
|
|
345
|
+
);
|
|
346
|
+
const dependencies = testSource
|
|
347
|
+
? context.package?.dependencies
|
|
348
|
+
: (context.package?.runtimeDependencies ?? context.package?.dependencies);
|
|
349
|
+
return !dependencies?.includes(targetPackage);
|
|
350
|
+
};
|
|
351
|
+
|
|
237
352
|
const isIdentifierNamed = (node: AstNode, name: string): boolean =>
|
|
238
353
|
node.type === 'Identifier' && identifierName(node) === name;
|
|
239
354
|
|
|
355
|
+
const stringLiteralNeedsReview = (
|
|
356
|
+
match: AstStringLiteralRenameClassOptions['match'],
|
|
357
|
+
contextKey: AstScopeContext['key']
|
|
358
|
+
): boolean =>
|
|
359
|
+
match === 'review' || (match === 'property-key' && contextKey !== 'key');
|
|
360
|
+
|
|
361
|
+
const identifierTokenSpan = (
|
|
362
|
+
node: AstNode,
|
|
363
|
+
source: string,
|
|
364
|
+
name: string
|
|
365
|
+
): { readonly end: number; readonly start: number } | null => {
|
|
366
|
+
const end = node.start + name.length;
|
|
367
|
+
return source.slice(node.start, end) === name
|
|
368
|
+
? { end, start: node.start }
|
|
369
|
+
: null;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const toPascalIdentifierSegment = (value: string): string =>
|
|
373
|
+
value.length === 0
|
|
374
|
+
? value
|
|
375
|
+
: `${value[0]?.toUpperCase() ?? ''}${value.slice(1)}`;
|
|
376
|
+
|
|
377
|
+
const isScreamingSnakeIdentifier = (name: string): boolean =>
|
|
378
|
+
/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(name);
|
|
379
|
+
|
|
380
|
+
const replaceScreamingSnakeIdentifierSegment = (
|
|
381
|
+
name: string,
|
|
382
|
+
from: string,
|
|
383
|
+
to: string
|
|
384
|
+
): string | null => {
|
|
385
|
+
if (!isScreamingSnakeIdentifier(name)) {
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const fromSegment = from.toUpperCase();
|
|
390
|
+
const toSegment = to.toUpperCase();
|
|
391
|
+
let changed = false;
|
|
392
|
+
const nextName = name
|
|
393
|
+
.split('_')
|
|
394
|
+
.map((segment) => {
|
|
395
|
+
if (segment !== fromSegment) {
|
|
396
|
+
return segment;
|
|
397
|
+
}
|
|
398
|
+
changed = true;
|
|
399
|
+
return toSegment;
|
|
400
|
+
})
|
|
401
|
+
.join('_');
|
|
402
|
+
|
|
403
|
+
return changed ? nextName : null;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const replaceCamelOrPascalIdentifierSegment = (
|
|
407
|
+
name: string,
|
|
408
|
+
from: string,
|
|
409
|
+
to: string
|
|
410
|
+
): string | null => {
|
|
411
|
+
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(name)) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const fromPascalSegment = toPascalIdentifierSegment(from);
|
|
416
|
+
const toPascalSegment = toPascalIdentifierSegment(to);
|
|
417
|
+
const segmentPattern = /[A-Z]+(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[0-9]+/g;
|
|
418
|
+
let cursor = 0;
|
|
419
|
+
let changed = false;
|
|
420
|
+
let nextName = '';
|
|
421
|
+
|
|
422
|
+
for (const match of name.matchAll(segmentPattern)) {
|
|
423
|
+
const [segment] = match;
|
|
424
|
+
const { index } = match;
|
|
425
|
+
if (index === undefined || index !== cursor) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (segment === from) {
|
|
430
|
+
changed = true;
|
|
431
|
+
nextName += to;
|
|
432
|
+
} else if (segment === fromPascalSegment) {
|
|
433
|
+
changed = true;
|
|
434
|
+
nextName += toPascalSegment;
|
|
435
|
+
} else {
|
|
436
|
+
nextName += segment;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
cursor = index + segment.length;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (cursor !== name.length || !changed) {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return nextName;
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const deriveIdentifierSegmentTarget = (
|
|
450
|
+
name: string,
|
|
451
|
+
from: string,
|
|
452
|
+
to: string
|
|
453
|
+
): string | null => {
|
|
454
|
+
const leadingUnderscores = /^_+/.exec(name)?.[0] ?? '';
|
|
455
|
+
const coreName =
|
|
456
|
+
leadingUnderscores.length > 0
|
|
457
|
+
? name.slice(leadingUnderscores.length)
|
|
458
|
+
: name;
|
|
459
|
+
if (coreName.length === 0) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const screamingSnakeTarget = replaceScreamingSnakeIdentifierSegment(
|
|
464
|
+
coreName,
|
|
465
|
+
from,
|
|
466
|
+
to
|
|
467
|
+
);
|
|
468
|
+
if (screamingSnakeTarget !== null) {
|
|
469
|
+
return `${leadingUnderscores}${screamingSnakeTarget}`;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const camelOrPascalTarget = replaceCamelOrPascalIdentifierSegment(
|
|
473
|
+
coreName,
|
|
474
|
+
from,
|
|
475
|
+
to
|
|
476
|
+
);
|
|
477
|
+
return camelOrPascalTarget === null
|
|
478
|
+
? null
|
|
479
|
+
: `${leadingUnderscores}${camelOrPascalTarget}`;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const hasIdentifierSegment = (name: string, segment: string): boolean =>
|
|
483
|
+
deriveIdentifierSegmentTarget(name, segment, segment) !== null;
|
|
484
|
+
|
|
485
|
+
interface AstIdentifierRenameMatch {
|
|
486
|
+
readonly from: string;
|
|
487
|
+
readonly span: { readonly end: number; readonly start: number } | null;
|
|
488
|
+
readonly to: string;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const resolveIdentifierRenameMatch = (
|
|
492
|
+
node: AstNode,
|
|
493
|
+
source: string,
|
|
494
|
+
options: {
|
|
495
|
+
readonly from: string;
|
|
496
|
+
readonly match: AstIdentifierRenameMatchMode;
|
|
497
|
+
readonly to: string;
|
|
498
|
+
}
|
|
499
|
+
): AstIdentifierRenameMatch | null => {
|
|
500
|
+
if (options.match === 'exact') {
|
|
501
|
+
if (!isIdentifierNamed(node, options.from)) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return {
|
|
506
|
+
from: options.from,
|
|
507
|
+
span: identifierTokenSpan(node, source, options.from),
|
|
508
|
+
to: options.to,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (node.type !== 'Identifier') {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const name = identifierName(node);
|
|
517
|
+
if (name === null) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
const target = deriveIdentifierSegmentTarget(name, options.from, options.to);
|
|
521
|
+
if (target === null) {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return {
|
|
526
|
+
from: name,
|
|
527
|
+
span: identifierTokenSpan(node, source, name),
|
|
528
|
+
to: target,
|
|
529
|
+
};
|
|
530
|
+
};
|
|
531
|
+
|
|
240
532
|
export const createAstIdentifierRenameClass = (
|
|
241
533
|
options: AstIdentifierRenameClassOptions
|
|
242
534
|
): RegradeClass => {
|
|
535
|
+
const matchMode = options.match ?? 'exact';
|
|
243
536
|
const reviewDeclarationTypes =
|
|
244
537
|
options.reviewDeclarationTypes ?? new Set<string>();
|
|
245
538
|
|
|
@@ -249,18 +542,235 @@ export const createAstIdentifierRenameClass = (
|
|
|
249
542
|
`Rename identifier "${options.from}" to "${options.to}".`,
|
|
250
543
|
id: options.id ?? `ast-identifier-rename:${options.from}->${options.to}`,
|
|
251
544
|
visit: (node, context) => {
|
|
252
|
-
|
|
545
|
+
const match = resolveIdentifierRenameMatch(node, context.source, {
|
|
546
|
+
from: options.from,
|
|
547
|
+
match: matchMode,
|
|
548
|
+
to: options.to,
|
|
549
|
+
});
|
|
550
|
+
if (match === null) {
|
|
253
551
|
return null;
|
|
254
552
|
}
|
|
255
553
|
|
|
256
|
-
const
|
|
257
|
-
if (
|
|
554
|
+
const { span } = match;
|
|
555
|
+
if (span === null) {
|
|
258
556
|
const location = offsetToLineColumn(context.source, node.start);
|
|
557
|
+
const caution = `Identifier "${match.from}" token span could not be verified; routed to review.`;
|
|
558
|
+
return {
|
|
559
|
+
detail: {
|
|
560
|
+
candidateReplacement: match.to,
|
|
561
|
+
expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
|
|
562
|
+
judgment: 'unresolved',
|
|
563
|
+
matchedForm: match.from,
|
|
564
|
+
nodeKind: node.type,
|
|
565
|
+
preserveCautions: [caution],
|
|
566
|
+
reason: 'ast-identifier-token-span-unverified',
|
|
567
|
+
signals: ['ast:identifier-rename'],
|
|
568
|
+
span: {
|
|
569
|
+
column: location.column,
|
|
570
|
+
end: node.end,
|
|
571
|
+
line: location.line,
|
|
572
|
+
start: node.start,
|
|
573
|
+
},
|
|
574
|
+
suggestedValidation: 'bun run typecheck',
|
|
575
|
+
symbol: match.from,
|
|
576
|
+
},
|
|
577
|
+
kind: 'review',
|
|
578
|
+
note: caution,
|
|
579
|
+
reason: 'ast-identifier-token-span-unverified',
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (
|
|
584
|
+
options.shouldPreserve?.({
|
|
585
|
+
end: span.end,
|
|
586
|
+
from: match.from,
|
|
587
|
+
path: context.path,
|
|
588
|
+
source: context.source,
|
|
589
|
+
start: span.start,
|
|
590
|
+
to: match.to,
|
|
591
|
+
}) === true
|
|
592
|
+
) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const existingTargetSegment = (
|
|
597
|
+
options.reviewExistingTargetSegments ?? [options.to]
|
|
598
|
+
).find((segment) => hasIdentifierSegment(match.from, segment));
|
|
599
|
+
if (
|
|
600
|
+
matchMode === 'identifier-segment' &&
|
|
601
|
+
existingTargetSegment !== undefined
|
|
602
|
+
) {
|
|
603
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
604
|
+
const caution = `Identifier "${match.from}" already contains target segment "${existingTargetSegment}"; routed to review.`;
|
|
259
605
|
return {
|
|
260
606
|
detail: {
|
|
261
|
-
|
|
607
|
+
candidateReplacement: match.to,
|
|
608
|
+
expectedTarget: `Review identifier "${match.from}" before replacing "${options.from}" with "${options.to}".`,
|
|
609
|
+
judgment: 'unresolved',
|
|
610
|
+
matchedForm: match.from,
|
|
262
611
|
nodeKind: node.type,
|
|
612
|
+
preserveCautions: [caution],
|
|
613
|
+
reason: 'ast-identifier-target-segment-present',
|
|
614
|
+
signals: ['ast:identifier-rename'],
|
|
615
|
+
span: {
|
|
616
|
+
column: location.column,
|
|
617
|
+
end: span.end,
|
|
618
|
+
line: location.line,
|
|
619
|
+
start: span.start,
|
|
620
|
+
},
|
|
621
|
+
suggestedValidation: 'bun run typecheck',
|
|
622
|
+
symbol: match.from,
|
|
623
|
+
},
|
|
624
|
+
kind: 'review',
|
|
625
|
+
note: caution,
|
|
626
|
+
reason: 'ast-identifier-target-segment-present',
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const declaration = context.getDeclaration(match.from);
|
|
631
|
+
if (declaration && reviewDeclarationTypes.has(declaration.type)) {
|
|
632
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
633
|
+
const caution = `Identifier "${match.from}" resolves to ${declaration.type}; routed to review.`;
|
|
634
|
+
return {
|
|
635
|
+
detail: {
|
|
636
|
+
candidateReplacement: match.to,
|
|
637
|
+
expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
|
|
638
|
+
judgment: 'unresolved',
|
|
639
|
+
matchedForm: match.from,
|
|
640
|
+
nodeKind: node.type,
|
|
641
|
+
preserveCautions: [caution],
|
|
263
642
|
reason: 'ast-identifier-review-declaration',
|
|
643
|
+
signals: ['ast:identifier-rename'],
|
|
644
|
+
span: {
|
|
645
|
+
column: location.column,
|
|
646
|
+
end: span.end,
|
|
647
|
+
line: location.line,
|
|
648
|
+
start: span.start,
|
|
649
|
+
},
|
|
650
|
+
suggestedValidation: 'bun run typecheck',
|
|
651
|
+
symbol: match.from,
|
|
652
|
+
},
|
|
653
|
+
kind: 'review',
|
|
654
|
+
note: caution,
|
|
655
|
+
reason: 'ast-identifier-review-declaration',
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (
|
|
660
|
+
context.parent?.type === 'ImportSpecifier' ||
|
|
661
|
+
context.parent?.type === 'ImportDefaultSpecifier' ||
|
|
662
|
+
context.parent?.type === 'ImportNamespaceSpecifier' ||
|
|
663
|
+
context.parent?.type === 'ExportSpecifier'
|
|
664
|
+
) {
|
|
665
|
+
const location = offsetToLineColumn(context.source, span.start);
|
|
666
|
+
const caution = `Identifier "${match.from}" names an import or export boundary; routed to review.`;
|
|
667
|
+
return {
|
|
668
|
+
detail: {
|
|
669
|
+
candidateReplacement: match.to,
|
|
670
|
+
expectedTarget: `Review public or foreign identifier "${match.from}" before renaming it to "${match.to}".`,
|
|
671
|
+
judgment: 'unresolved',
|
|
672
|
+
matchedForm: match.from,
|
|
673
|
+
nodeKind: node.type,
|
|
674
|
+
preserveCautions: [caution],
|
|
675
|
+
reason: 'ast-identifier-module-boundary',
|
|
676
|
+
signals: ['ast:identifier-rename'],
|
|
677
|
+
span: {
|
|
678
|
+
column: location.column,
|
|
679
|
+
end: span.end,
|
|
680
|
+
line: location.line,
|
|
681
|
+
start: span.start,
|
|
682
|
+
},
|
|
683
|
+
suggestedValidation: 'bun run typecheck',
|
|
684
|
+
symbol: match.from,
|
|
685
|
+
},
|
|
686
|
+
kind: 'review',
|
|
687
|
+
note: caution,
|
|
688
|
+
reason: 'ast-identifier-module-boundary',
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
return {
|
|
693
|
+
edit: createSourceEdit(span.start, span.end, match.to),
|
|
694
|
+
kind: 'edit',
|
|
695
|
+
note: `Renamed identifier "${match.from}" to "${match.to}".`,
|
|
696
|
+
};
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const stringLiteralValueSpan = (
|
|
702
|
+
node: AstNode,
|
|
703
|
+
source: string,
|
|
704
|
+
value: string
|
|
705
|
+
): { readonly end: number; readonly start: number } | null => {
|
|
706
|
+
const raw = source.slice(node.start, node.end);
|
|
707
|
+
const relativeStart = raw.indexOf(value);
|
|
708
|
+
if (relativeStart === -1 || raw.includes(value, relativeStart + 1)) {
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
const start = node.start + relativeStart;
|
|
712
|
+
return { end: start + value.length, start };
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
const isAdjacentModuleRoute = (value: string, route: string): boolean =>
|
|
716
|
+
value.startsWith(`${route}/`) || value.startsWith(`${route}.`);
|
|
717
|
+
|
|
718
|
+
interface AstStringLiteralMatch {
|
|
719
|
+
readonly adjacentModuleRoute: boolean;
|
|
720
|
+
readonly value: string;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const matchAstStringLiteral = (
|
|
724
|
+
node: AstNode,
|
|
725
|
+
options: AstStringLiteralRenameClassOptions
|
|
726
|
+
): AstStringLiteralMatch | null => {
|
|
727
|
+
if (!isStringLiteral(node) && node.type !== 'TemplateLiteral') {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
const value = isStringLiteral(node)
|
|
731
|
+
? getStringValue(node)
|
|
732
|
+
: extractPlainTemplateLiteral(node);
|
|
733
|
+
if (value === null) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
const adjacentModuleRoute =
|
|
737
|
+
options.allowModuleSpecifier === true &&
|
|
738
|
+
isAdjacentModuleRoute(value, options.from);
|
|
739
|
+
return value === options.from || adjacentModuleRoute
|
|
740
|
+
? { adjacentModuleRoute, value }
|
|
741
|
+
: null;
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
export const createAstStringLiteralRenameClass = (
|
|
745
|
+
options: AstStringLiteralRenameClassOptions
|
|
746
|
+
): RegradeClass =>
|
|
747
|
+
createAstRewriteClass({
|
|
748
|
+
describe:
|
|
749
|
+
options.describe ??
|
|
750
|
+
`Rename string literal "${options.from}" to "${options.to}".`,
|
|
751
|
+
id:
|
|
752
|
+
options.id ?? `ast-string-literal-rename:${options.from}->${options.to}`,
|
|
753
|
+
visit: (node, context) => {
|
|
754
|
+
const match = matchAstStringLiteral(node, options);
|
|
755
|
+
if (match === null) {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
const { adjacentModuleRoute, value } = match;
|
|
759
|
+
|
|
760
|
+
const span = stringLiteralValueSpan(node, context.source, value);
|
|
761
|
+
if (span === null) {
|
|
762
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
763
|
+
const caution = `String literal "${options.from}" token span could not be verified; routed to review.`;
|
|
764
|
+
return {
|
|
765
|
+
detail: {
|
|
766
|
+
candidateReplacement: options.to,
|
|
767
|
+
expectedTarget: `Rename string literal "${options.from}" to "${options.to}".`,
|
|
768
|
+
judgment: 'unresolved',
|
|
769
|
+
matchedForm: options.from,
|
|
770
|
+
nodeKind: node.type,
|
|
771
|
+
preserveCautions: [caution],
|
|
772
|
+
reason: 'ast-string-literal-token-span-unverified',
|
|
773
|
+
signals: ['ast:string-literal-rename'],
|
|
264
774
|
span: {
|
|
265
775
|
column: location.column,
|
|
266
776
|
end: node.end,
|
|
@@ -271,16 +781,214 @@ export const createAstIdentifierRenameClass = (
|
|
|
271
781
|
symbol: options.from,
|
|
272
782
|
},
|
|
273
783
|
kind: 'review',
|
|
274
|
-
note:
|
|
275
|
-
reason: 'ast-
|
|
784
|
+
note: caution,
|
|
785
|
+
reason: 'ast-string-literal-token-span-unverified',
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (
|
|
790
|
+
options.shouldPreserve?.({
|
|
791
|
+
end: span.end,
|
|
792
|
+
from: value,
|
|
793
|
+
path: context.path,
|
|
794
|
+
source: context.source,
|
|
795
|
+
start: span.start,
|
|
796
|
+
to: options.to,
|
|
797
|
+
}) === true
|
|
798
|
+
) {
|
|
799
|
+
return null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (adjacentModuleRoute) {
|
|
803
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
804
|
+
const caution = `Module route "${value}" is adjacent to governed route "${options.from}"; routed to review.`;
|
|
805
|
+
return {
|
|
806
|
+
detail: {
|
|
807
|
+
candidateReplacement: `${options.to}${value.slice(options.from.length)}`,
|
|
808
|
+
expectedTarget: `Review whether module route "${value}" moves with governed route "${options.from}".`,
|
|
809
|
+
judgment: 'unresolved',
|
|
810
|
+
matchedForm: value,
|
|
811
|
+
nodeKind: node.type,
|
|
812
|
+
preserveCautions: [caution],
|
|
813
|
+
reason: 'ast-string-literal-adjacent-module-route',
|
|
814
|
+
signals: ['ast:string-literal-rename', 'ast:module-specifier'],
|
|
815
|
+
span: {
|
|
816
|
+
column: location.column,
|
|
817
|
+
end: node.end,
|
|
818
|
+
line: location.line,
|
|
819
|
+
start: node.start,
|
|
820
|
+
},
|
|
821
|
+
suggestedValidation:
|
|
822
|
+
'Confirm whether the adjacent module route is public, private, or intentionally preserved.',
|
|
823
|
+
symbol: value,
|
|
824
|
+
},
|
|
825
|
+
kind: 'review',
|
|
826
|
+
note: caution,
|
|
827
|
+
reason: 'ast-string-literal-adjacent-module-route',
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (
|
|
832
|
+
options.allowModuleSpecifier !== true &&
|
|
833
|
+
isModuleSpecifierPosition(context)
|
|
834
|
+
) {
|
|
835
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
836
|
+
const caution = `String literal "${options.from}" is a module specifier; routed to review.`;
|
|
837
|
+
return {
|
|
838
|
+
detail: {
|
|
839
|
+
candidateReplacement: options.to,
|
|
840
|
+
expectedTarget: `Review module route "${options.from}" before renaming it to "${options.to}".`,
|
|
841
|
+
judgment: 'unresolved',
|
|
842
|
+
matchedForm: options.from,
|
|
843
|
+
nodeKind: node.type,
|
|
844
|
+
preserveCautions: [caution],
|
|
845
|
+
reason: 'ast-string-literal-module-specifier',
|
|
846
|
+
signals: ['ast:string-literal-rename', 'ast:module-specifier'],
|
|
847
|
+
span: {
|
|
848
|
+
column: location.column,
|
|
849
|
+
end: node.end,
|
|
850
|
+
line: location.line,
|
|
851
|
+
start: node.start,
|
|
852
|
+
},
|
|
853
|
+
suggestedValidation:
|
|
854
|
+
'Confirm the module route is owned by this transition before changing it.',
|
|
855
|
+
symbol: options.from,
|
|
856
|
+
},
|
|
857
|
+
kind: 'review',
|
|
858
|
+
note: caution,
|
|
859
|
+
reason: 'ast-string-literal-module-specifier',
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (isTargetPackageMissing(context, options.targetPackage)) {
|
|
864
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
865
|
+
const manifest = context.package?.path ?? 'the owning package.json';
|
|
866
|
+
const invalidManifest = context.package?.manifestState === 'invalid';
|
|
867
|
+
const caution = invalidManifest
|
|
868
|
+
? `Package manifest ${manifest} is invalid; routed to review before rewriting package route "${options.to}".`
|
|
869
|
+
: `Package route "${options.to}" is not declared by ${manifest}; routed to review before rewriting source.`;
|
|
870
|
+
return {
|
|
871
|
+
detail: {
|
|
872
|
+
candidateReplacement: options.to,
|
|
873
|
+
expectedTarget: invalidManifest
|
|
874
|
+
? `Fix invalid package manifest ${manifest}, declare "${options.targetPackage}", install dependencies, then rename "${options.from}" to "${options.to}".`
|
|
875
|
+
: `Declare "${options.targetPackage}" in ${manifest}, install dependencies, then rename "${options.from}" to "${options.to}".`,
|
|
876
|
+
judgment: 'unresolved',
|
|
877
|
+
matchedForm: options.from,
|
|
878
|
+
nodeKind: node.type,
|
|
879
|
+
preserveCautions: [caution],
|
|
880
|
+
reason: 'package-route-target-dependency-unverified',
|
|
881
|
+
signals: [
|
|
882
|
+
'ast:string-literal-rename',
|
|
883
|
+
'package:dependency',
|
|
884
|
+
...(invalidManifest ? ['package:manifest-invalid'] : []),
|
|
885
|
+
],
|
|
886
|
+
span: {
|
|
887
|
+
column: location.column,
|
|
888
|
+
end: node.end,
|
|
889
|
+
line: location.line,
|
|
890
|
+
start: node.start,
|
|
891
|
+
},
|
|
892
|
+
suggestedValidation: invalidManifest
|
|
893
|
+
? 'Repair the package manifest, install dependencies, and run the package typecheck.'
|
|
894
|
+
: 'Install dependencies and run the package typecheck.',
|
|
895
|
+
symbol: options.from,
|
|
896
|
+
},
|
|
897
|
+
kind: 'review',
|
|
898
|
+
note: caution,
|
|
899
|
+
reason: 'package-route-target-dependency-unverified',
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (stringLiteralNeedsReview(options.match, context.key)) {
|
|
904
|
+
const location = offsetToLineColumn(context.source, node.start);
|
|
905
|
+
const caution = `String literal "${options.from}" is not proven framework-owned; routed to review.`;
|
|
906
|
+
return {
|
|
907
|
+
detail: {
|
|
908
|
+
candidateReplacement: options.to,
|
|
909
|
+
expectedTarget: `Review whether string literal "${options.from}" is framework-owned before renaming it to "${options.to}".`,
|
|
910
|
+
judgment: 'unresolved',
|
|
911
|
+
matchedForm: options.from,
|
|
912
|
+
nodeKind: node.type,
|
|
913
|
+
preserveCautions: [caution],
|
|
914
|
+
reason: 'ast-string-literal-review-position',
|
|
915
|
+
signals: [
|
|
916
|
+
'ast:string-literal-rename',
|
|
917
|
+
'ast:framework-position-required',
|
|
918
|
+
],
|
|
919
|
+
span: {
|
|
920
|
+
column: location.column,
|
|
921
|
+
end: node.end,
|
|
922
|
+
line: location.line,
|
|
923
|
+
start: node.start,
|
|
924
|
+
},
|
|
925
|
+
suggestedValidation:
|
|
926
|
+
'Confirm the literal is owned by a Trails contract before changing it.',
|
|
927
|
+
symbol: options.from,
|
|
928
|
+
},
|
|
929
|
+
kind: 'review',
|
|
930
|
+
note: caution,
|
|
931
|
+
reason: 'ast-string-literal-review-position',
|
|
276
932
|
};
|
|
277
933
|
}
|
|
278
934
|
|
|
279
935
|
return {
|
|
280
|
-
edit: createSourceEdit(
|
|
936
|
+
edit: createSourceEdit(span.start, span.end, options.to),
|
|
281
937
|
kind: 'edit',
|
|
282
|
-
note: `Renamed
|
|
938
|
+
note: `Renamed string literal "${options.from}" to "${options.to}".`,
|
|
283
939
|
};
|
|
284
940
|
},
|
|
285
941
|
});
|
|
942
|
+
|
|
943
|
+
export const createGovernedAstIdentifierRenameClasses = (
|
|
944
|
+
transition: GovernedVocabularyTransition,
|
|
945
|
+
options: {
|
|
946
|
+
readonly shouldPreserve?: (
|
|
947
|
+
occurrence: AstIdentifierRenameOccurrence
|
|
948
|
+
) => boolean;
|
|
949
|
+
} = {}
|
|
950
|
+
): readonly RegradeClass[] => {
|
|
951
|
+
const targetSegments = [
|
|
952
|
+
...new Set(transition.symbolRenames.map((rename) => rename.to)),
|
|
953
|
+
];
|
|
954
|
+
const orderedLiteralRenames = [...transition.stringLiteralRenames].toSorted(
|
|
955
|
+
(left, right) =>
|
|
956
|
+
right.from.length - left.from.length ||
|
|
957
|
+
left.from.localeCompare(right.from)
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
return [
|
|
961
|
+
...transition.symbolRenames.map((rename) =>
|
|
962
|
+
createAstIdentifierRenameClass({
|
|
963
|
+
describe: `Rename governed symbol "${rename.from}" to "${rename.to}" for ${transition.id}.`,
|
|
964
|
+
from: rename.from,
|
|
965
|
+
id: `ast-symbol-rename:${transition.id}:${rename.from}->${rename.to}`,
|
|
966
|
+
match: rename.match,
|
|
967
|
+
reviewDeclarationTypes: new Set(rename.reviewDeclarationTypes),
|
|
968
|
+
reviewExistingTargetSegments: targetSegments,
|
|
969
|
+
...(options.shouldPreserve === undefined
|
|
970
|
+
? {}
|
|
971
|
+
: { shouldPreserve: options.shouldPreserve }),
|
|
972
|
+
to: rename.to,
|
|
973
|
+
})
|
|
974
|
+
),
|
|
975
|
+
...orderedLiteralRenames.map((rename) =>
|
|
976
|
+
createAstStringLiteralRenameClass({
|
|
977
|
+
describe: `Rename governed string literal "${rename.from}" to "${rename.to}" for ${transition.id}.`,
|
|
978
|
+
from: rename.from,
|
|
979
|
+
id: `ast-string-literal-rename:${transition.id}:${rename.from}->${rename.to}`,
|
|
980
|
+
...(rename.match === undefined ? {} : { match: rename.match }),
|
|
981
|
+
...(options.shouldPreserve === undefined
|
|
982
|
+
? {}
|
|
983
|
+
: { shouldPreserve: options.shouldPreserve }),
|
|
984
|
+
...(rename.moduleSpecifier === undefined
|
|
985
|
+
? {}
|
|
986
|
+
: {
|
|
987
|
+
allowModuleSpecifier: true,
|
|
988
|
+
targetPackage: rename.moduleSpecifier.targetPackage,
|
|
989
|
+
}),
|
|
990
|
+
to: rename.to,
|
|
991
|
+
})
|
|
992
|
+
),
|
|
993
|
+
];
|
|
286
994
|
};
|