@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.
@@ -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/warden/ast';
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 extends AstScopeContext {
24
- readonly path: string;
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
- path: context.path,
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
- if (!isIdentifierNamed(node, options.from)) {
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 declaration = context.getDeclaration(options.from);
257
- if (declaration && reviewDeclarationTypes.has(declaration.type)) {
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
- expectedTarget: `Rename identifier "${options.from}" to "${options.to}".`,
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: `Identifier "${options.from}" resolves to ${declaration.type}; routed to review.`,
275
- reason: 'ast-identifier-review-declaration',
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(node.start, node.end, options.to),
936
+ edit: createSourceEdit(span.start, span.end, options.to),
281
937
  kind: 'edit',
282
- note: `Renamed identifier "${options.from}" to "${options.to}".`,
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
  };