@ontrails/regrade 1.0.0-beta.39 → 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,19 +1,18 @@
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,
9
7
  getStringValue,
10
8
  identifierName,
9
+ isMemberExpression,
11
10
  isStringLiteral,
12
11
  offsetToLineColumn,
13
12
  parseWithDiagnostics,
14
13
  validateSourceEdits,
15
14
  walkWithScopeContext,
16
- } from '@ontrails/warden/ast';
15
+ } from '@ontrails/source';
17
16
  import type { GovernedVocabularyTransition } from '@ontrails/warden';
18
17
 
19
18
  import type {
@@ -23,8 +22,8 @@ import type {
23
22
  RegradeReviewDetail,
24
23
  } from './report.js';
25
24
 
26
- export interface AstRewriteContext extends AstScopeContext {
27
- readonly path: string;
25
+ export interface AstRewriteContext
26
+ extends AstScopeContext, RegradeClassContext {
28
27
  readonly source: string;
29
28
  }
30
29
 
@@ -179,7 +178,7 @@ export const createAstRewriteClass = (
179
178
  ...toArray(
180
179
  options.visit(node, {
181
180
  ...scopeContext,
182
- path: context.path,
181
+ ...context,
183
182
  source,
184
183
  })
185
184
  )
@@ -233,13 +232,17 @@ export interface AstIdentifierRenameClassOptions {
233
232
  readonly describe?: string;
234
233
  readonly from: string;
235
234
  readonly id?: string;
235
+ readonly match?: AstIdentifierRenameMatchMode;
236
236
  readonly reviewDeclarationTypes?: ReadonlySet<string>;
237
+ readonly reviewExistingTargetSegments?: readonly string[];
237
238
  readonly shouldPreserve?: (
238
239
  occurrence: AstIdentifierRenameOccurrence
239
240
  ) => boolean;
240
241
  readonly to: string;
241
242
  }
242
243
 
244
+ export type AstIdentifierRenameMatchMode = 'exact' | 'identifier-segment';
245
+
243
246
  export interface AstIdentifierRenameOccurrence {
244
247
  readonly end: number;
245
248
  readonly from: string;
@@ -250,18 +253,111 @@ export interface AstIdentifierRenameOccurrence {
250
253
  }
251
254
 
252
255
  export interface AstStringLiteralRenameClassOptions {
256
+ readonly allowModuleSpecifier?: boolean;
253
257
  readonly describe?: string;
254
258
  readonly from: string;
255
259
  readonly id?: string;
260
+ readonly match?: 'exact' | 'property-key' | 'review';
256
261
  readonly shouldPreserve?: (
257
262
  occurrence: AstIdentifierRenameOccurrence
258
263
  ) => boolean;
264
+ /** Require the owning manifest to already admit this package route. */
265
+ readonly targetPackage?: string;
259
266
  readonly to: string;
260
267
  }
261
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
+
262
352
  const isIdentifierNamed = (node: AstNode, name: string): boolean =>
263
353
  node.type === 'Identifier' && identifierName(node) === name;
264
354
 
355
+ const stringLiteralNeedsReview = (
356
+ match: AstStringLiteralRenameClassOptions['match'],
357
+ contextKey: AstScopeContext['key']
358
+ ): boolean =>
359
+ match === 'review' || (match === 'property-key' && contextKey !== 'key');
360
+
265
361
  const identifierTokenSpan = (
266
362
  node: AstNode,
267
363
  source: string,
@@ -273,9 +369,170 @@ const identifierTokenSpan = (
273
369
  : null;
274
370
  };
275
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
+
276
532
  export const createAstIdentifierRenameClass = (
277
533
  options: AstIdentifierRenameClassOptions
278
534
  ): RegradeClass => {
535
+ const matchMode = options.match ?? 'exact';
279
536
  const reviewDeclarationTypes =
280
537
  options.reviewDeclarationTypes ?? new Set<string>();
281
538
 
@@ -285,20 +542,25 @@ export const createAstIdentifierRenameClass = (
285
542
  `Rename identifier "${options.from}" to "${options.to}".`,
286
543
  id: options.id ?? `ast-identifier-rename:${options.from}->${options.to}`,
287
544
  visit: (node, context) => {
288
- 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) {
289
551
  return null;
290
552
  }
291
553
 
292
- const span = identifierTokenSpan(node, context.source, options.from);
554
+ const { span } = match;
293
555
  if (span === null) {
294
556
  const location = offsetToLineColumn(context.source, node.start);
295
- const caution = `Identifier "${options.from}" token span could not be verified; routed to review.`;
557
+ const caution = `Identifier "${match.from}" token span could not be verified; routed to review.`;
296
558
  return {
297
559
  detail: {
298
- candidateReplacement: options.to,
299
- expectedTarget: `Rename identifier "${options.from}" to "${options.to}".`,
560
+ candidateReplacement: match.to,
561
+ expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
300
562
  judgment: 'unresolved',
301
- matchedForm: options.from,
563
+ matchedForm: match.from,
302
564
  nodeKind: node.type,
303
565
  preserveCautions: [caution],
304
566
  reason: 'ast-identifier-token-span-unverified',
@@ -310,7 +572,7 @@ export const createAstIdentifierRenameClass = (
310
572
  start: node.start,
311
573
  },
312
574
  suggestedValidation: 'bun run typecheck',
313
- symbol: options.from,
575
+ symbol: match.from,
314
576
  },
315
577
  kind: 'review',
316
578
  note: caution,
@@ -321,26 +583,60 @@ export const createAstIdentifierRenameClass = (
321
583
  if (
322
584
  options.shouldPreserve?.({
323
585
  end: span.end,
324
- from: options.from,
586
+ from: match.from,
325
587
  path: context.path,
326
588
  source: context.source,
327
589
  start: span.start,
328
- to: options.to,
590
+ to: match.to,
329
591
  }) === true
330
592
  ) {
331
593
  return null;
332
594
  }
333
595
 
334
- const declaration = context.getDeclaration(options.from);
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.`;
605
+ return {
606
+ detail: {
607
+ candidateReplacement: match.to,
608
+ expectedTarget: `Review identifier "${match.from}" before replacing "${options.from}" with "${options.to}".`,
609
+ judgment: 'unresolved',
610
+ matchedForm: match.from,
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);
335
631
  if (declaration && reviewDeclarationTypes.has(declaration.type)) {
336
632
  const location = offsetToLineColumn(context.source, span.start);
337
- const caution = `Identifier "${options.from}" resolves to ${declaration.type}; routed to review.`;
633
+ const caution = `Identifier "${match.from}" resolves to ${declaration.type}; routed to review.`;
338
634
  return {
339
635
  detail: {
340
- candidateReplacement: options.to,
341
- expectedTarget: `Rename identifier "${options.from}" to "${options.to}".`,
636
+ candidateReplacement: match.to,
637
+ expectedTarget: `Rename identifier "${match.from}" to "${match.to}".`,
342
638
  judgment: 'unresolved',
343
- matchedForm: options.from,
639
+ matchedForm: match.from,
344
640
  nodeKind: node.type,
345
641
  preserveCautions: [caution],
346
642
  reason: 'ast-identifier-review-declaration',
@@ -352,7 +648,7 @@ export const createAstIdentifierRenameClass = (
352
648
  start: span.start,
353
649
  },
354
650
  suggestedValidation: 'bun run typecheck',
355
- symbol: options.from,
651
+ symbol: match.from,
356
652
  },
357
653
  kind: 'review',
358
654
  note: caution,
@@ -360,10 +656,43 @@ export const createAstIdentifierRenameClass = (
360
656
  };
361
657
  }
362
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
+
363
692
  return {
364
- edit: createSourceEdit(span.start, span.end, options.to),
693
+ edit: createSourceEdit(span.start, span.end, match.to),
365
694
  kind: 'edit',
366
- note: `Renamed identifier "${options.from}" to "${options.to}".`,
695
+ note: `Renamed identifier "${match.from}" to "${match.to}".`,
367
696
  };
368
697
  },
369
698
  });
@@ -383,6 +712,35 @@ const stringLiteralValueSpan = (
383
712
  return { end: start + value.length, start };
384
713
  };
385
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
+
386
744
  export const createAstStringLiteralRenameClass = (
387
745
  options: AstStringLiteralRenameClassOptions
388
746
  ): RegradeClass =>
@@ -393,11 +751,13 @@ export const createAstStringLiteralRenameClass = (
393
751
  id:
394
752
  options.id ?? `ast-string-literal-rename:${options.from}->${options.to}`,
395
753
  visit: (node, context) => {
396
- if (!isStringLiteral(node) || getStringValue(node) !== options.from) {
754
+ const match = matchAstStringLiteral(node, options);
755
+ if (match === null) {
397
756
  return null;
398
757
  }
758
+ const { adjacentModuleRoute, value } = match;
399
759
 
400
- const span = stringLiteralValueSpan(node, context.source, options.from);
760
+ const span = stringLiteralValueSpan(node, context.source, value);
401
761
  if (span === null) {
402
762
  const location = offsetToLineColumn(context.source, node.start);
403
763
  const caution = `String literal "${options.from}" token span could not be verified; routed to review.`;
@@ -429,7 +789,7 @@ export const createAstStringLiteralRenameClass = (
429
789
  if (
430
790
  options.shouldPreserve?.({
431
791
  end: span.end,
432
- from: options.from,
792
+ from: value,
433
793
  path: context.path,
434
794
  source: context.source,
435
795
  start: span.start,
@@ -439,6 +799,139 @@ export const createAstStringLiteralRenameClass = (
439
799
  return null;
440
800
  }
441
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',
932
+ };
933
+ }
934
+
442
935
  return {
443
936
  edit: createSourceEdit(span.start, span.end, options.to),
444
937
  kind: 'edit',
@@ -454,28 +947,48 @@ export const createGovernedAstIdentifierRenameClasses = (
454
947
  occurrence: AstIdentifierRenameOccurrence
455
948
  ) => boolean;
456
949
  } = {}
457
- ): readonly RegradeClass[] => [
458
- ...transition.symbolRenames.map((rename) =>
459
- createAstIdentifierRenameClass({
460
- describe: `Rename governed symbol "${rename.from}" to "${rename.to}" for ${transition.id}.`,
461
- from: rename.from,
462
- id: `ast-symbol-rename:${transition.id}:${rename.from}->${rename.to}`,
463
- reviewDeclarationTypes: new Set(rename.reviewDeclarationTypes),
464
- ...(options.shouldPreserve === undefined
465
- ? {}
466
- : { shouldPreserve: options.shouldPreserve }),
467
- to: rename.to,
468
- })
469
- ),
470
- ...transition.stringLiteralRenames.map((rename) =>
471
- createAstStringLiteralRenameClass({
472
- describe: `Rename governed string literal "${rename.from}" to "${rename.to}" for ${transition.id}.`,
473
- from: rename.from,
474
- id: `ast-string-literal-rename:${transition.id}:${rename.from}->${rename.to}`,
475
- ...(options.shouldPreserve === undefined
476
- ? {}
477
- : { shouldPreserve: options.shouldPreserve }),
478
- to: rename.to,
479
- })
480
- ),
481
- ];
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
+ ];
994
+ };