@xstate/codemod 0.1.0-alpha.3 → 0.1.0-alpha.4

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  <!-- CLI commands and transforms from src/cli.ts and src/transforms/index.ts -->
4
4
  Run `xstate-codemod migrate [globs...] --dry` to review a v5-to-v6 migration. Omit `--dry` to save changes. The default scan includes TypeScript and JavaScript files, excluding `node_modules` and `dist`.
5
5
 
6
- The transforms rename imports, wrap string targets, convert inline `types` declarations to schemas, and report APIs requiring manual migration. `--transform name,name` selects transforms.
6
+ The transforms rename imports, wrap string targets, convert inline `types` declarations to schemas, report APIs requiring manual migration, and report `#id.path` targets that v6 no longer resolves. `--transform name,name` selects transforms.
7
7
 
8
8
  <!-- helper binding and output validation from src/util.ts, src/transforms/types-to-schemas.ts and src/runner.ts -->
9
9
  The codemod supports named, namespace and type-only imports. It checks transformed syntax before saving. Run your application's typecheck and tests after migration, and review any reported manual migration steps.
@@ -1,4 +1,4 @@
1
1
  export type { Transform, TransformResult } from "./types.js";
2
- export { transforms, transformsByName, renameImports, stringTargets, typesToSchemas, reportRemovedApis } from "./transforms/index.js";
2
+ export { transforms, transformsByName, renameImports, stringTargets, typesToSchemas, reportRemovedApis, reportIdPathTargets } from "./transforms/index.js";
3
3
  export { run, applyToSourceFile, selectTransforms, type RunReport, type FileTransformReport } from "./runner.js";
4
4
  export { main, parseArgs, resolveFiles } from "./cli.js";
@@ -13,7 +13,7 @@ export interface FileTransformReport {
13
13
  export interface RunReport {
14
14
  files: FileTransformReport[];
15
15
  filesChanged: number;
16
- /** Notes emitted by `report-removed-apis` (manual-migration section). */
16
+ /** Notes emitted by the report-only transforms (manual-migration section). */
17
17
  manualMigrationNotes: string[];
18
18
  }
19
19
  /** Resolve a list of transform names to Transform objects. */
@@ -3,7 +3,8 @@ import { renameImports } from "./rename-imports.js";
3
3
  import { stringTargets } from "./string-targets.js";
4
4
  import { typesToSchemas } from "./types-to-schemas.js";
5
5
  import { reportRemovedApis } from "./report-removed-apis.js";
6
+ import { reportIdPathTargets } from "./report-id-path-targets.js";
6
7
  /** All transforms, in recommended application order. */
7
8
  export declare const transforms: Transform[];
8
9
  export declare const transformsByName: Record<string, Transform>;
9
- export { renameImports, stringTargets, typesToSchemas, reportRemovedApis };
10
+ export { renameImports, stringTargets, typesToSchemas, reportRemovedApis, reportIdPathTargets };
@@ -0,0 +1,8 @@
1
+ import type { Transform } from "../types.js";
2
+ /**
3
+ * Reports (does NOT transform) `#a.b` targets and `matches('#a.b')`/
4
+ * `stateIn('#a.b')` references that the codemod cannot verify as whole state
5
+ * ids. In v6, the text after `#` is an exact id and is never split into an
6
+ * id followed by a path.
7
+ */
8
+ export declare const reportIdPathTargets: Transform;
@@ -12,7 +12,8 @@ const RENAMES = {
12
12
  ExecutableActionsFrom: 'ExecutableEffectFrom',
13
13
  ExecutableSpawnAction: 'SpawnExecutableEffect',
14
14
  SpecialExecutableAction: 'SpecialExecutableEffect',
15
- ToExecutableAction: 'ToExecutableEffect'
15
+ ToExecutableAction: 'ToExecutableEffect',
16
+ ActorRefFromLogic: 'ActorRefFrom'
16
17
  };
17
18
 
18
19
  /**
@@ -22,7 +23,7 @@ const RENAMES = {
22
23
  */
23
24
  const renameImports = {
24
25
  name: 'rename-imports',
25
- description: "Rename renamed 'xstate' imports (interpret→createActor, Interpreter→Actor, fromCallback/fromObservable/fromEventObservable→create*Logic, executable action types→executable effect types) and their usages.",
26
+ description: "Rename renamed 'xstate' imports (interpret→createActor, Interpreter→Actor, fromCallback/fromObservable/fromEventObservable→create*Logic, executable action types→executable effect types, ActorRefFromLogic→ActorRefFrom) and their usages.",
26
27
  apply(sourceFile) {
27
28
  const notes = [];
28
29
  let changed = false;
@@ -49,7 +50,12 @@ const renameImports = {
49
50
  ref.replaceWithText(replacement);
50
51
  }
51
52
  }
52
- nameNode.replaceWithText(replacement);
53
+ const alreadyImported = imp.getNamedImports().some(other => other !== spec && !other.getAliasNode() && other.getNameNode().getText() === replacement);
54
+ if (alreadyImported) {
55
+ spec.remove();
56
+ } else {
57
+ nameNode.replaceWithText(replacement);
58
+ }
53
59
  notes.push(`renamed '${importedName}' → '${replacement}'${refs.length ? ` and ${refs.length} usage(s)` : ''}`);
54
60
  }
55
61
  changed = true;
@@ -515,10 +521,153 @@ const reportRemovedApis = {
515
521
  }
516
522
  };
517
523
 
524
+ /** Keys whose string values (or string array elements) are state targets. */
525
+ const TARGET_KEYS = new Set(['target', 'initial', 'on', 'after', 'always', 'onDone', 'onError']);
526
+
527
+ /** Calls whose first argument is a state value or `#id` reference. */
528
+ const MATCH_CALLS = new Set(['matches', 'stateIn']);
529
+ function getPropertyName(node) {
530
+ if (Node.isPropertyAssignment(node)) {
531
+ const name = node.getNameNode();
532
+ if (Node.isIdentifier(name) || Node.isStringLiteral(name)) {
533
+ return Node.isStringLiteral(name) ? name.getLiteralText() : name.getText();
534
+ }
535
+ }
536
+ return undefined;
537
+ }
538
+ function getStringProperty(object, name) {
539
+ const property = object.getProperty(name);
540
+ if (!property || !Node.isPropertyAssignment(property)) return undefined;
541
+ const initializer = property.getInitializer();
542
+ return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralText() : undefined;
543
+ }
544
+ /**
545
+ * Collects the state ids that one machine config literal declares: explicit
546
+ * state `id`s and, for `createMachine` configs, implicit ids (the path from
547
+ * the root, prefixed by the machine id when one is set). Invoke and actor ids
548
+ * are not state ids and are not collected.
549
+ */
550
+ function collectStateIds(config, withImplicitIds) {
551
+ const ids = new Set();
552
+ function walk(stateConfig, implicitId) {
553
+ const explicitId = getStringProperty(stateConfig, 'id');
554
+ if (explicitId !== undefined) ids.add(explicitId);else if (withImplicitIds) ids.add(implicitId);
555
+ const states = stateConfig.getProperty('states');
556
+ if (!states || !Node.isPropertyAssignment(states)) return;
557
+ const statesObject = states.getInitializer();
558
+ if (!statesObject || !Node.isObjectLiteralExpression(statesObject)) return;
559
+ for (const state of statesObject.getProperties()) {
560
+ const key = getPropertyName(state);
561
+ if (key === undefined || !Node.isPropertyAssignment(state)) continue;
562
+ const child = state.getInitializer();
563
+ if (!child || !Node.isObjectLiteralExpression(child)) continue;
564
+ walk(child, implicitId ? `${implicitId}.${key}` : key);
565
+ }
566
+ }
567
+ walk(config, getStringProperty(config, 'id') ?? '');
568
+ return ids;
569
+ }
570
+
571
+ /**
572
+ * Finds the `createMachine(...)` or `createStateConfig(...)` config literal
573
+ * that contains `node`, if any.
574
+ */
575
+ function findEnclosingConfig(node, machineConfigs, stateConfigs) {
576
+ for (const ancestor of node.getAncestors()) {
577
+ if (!Node.isObjectLiteralExpression(ancestor)) continue;
578
+ if (machineConfigs.has(ancestor)) {
579
+ return {
580
+ config: ancestor,
581
+ withImplicitIds: true
582
+ };
583
+ }
584
+ if (stateConfigs.has(ancestor)) {
585
+ return {
586
+ config: ancestor,
587
+ withImplicitIds: false
588
+ };
589
+ }
590
+ }
591
+ return undefined;
592
+ }
593
+ function isMatchArgument(literal) {
594
+ const call = literal.getParent();
595
+ if (!call || !Node.isCallExpression(call)) return false;
596
+ if (call.getArguments()[0] !== literal) return false;
597
+ const expression = call.getExpression();
598
+ const name = Node.isIdentifier(expression) ? expression.getText() : Node.isPropertyAccessExpression(expression) ? expression.getName() : undefined;
599
+ return !!name && MATCH_CALLS.has(name);
600
+ }
601
+ function isTargetValue(literal) {
602
+ let node = literal;
603
+ let parent = node.getParent();
604
+ if (parent && Node.isArrayLiteralExpression(parent)) {
605
+ node = parent;
606
+ parent = node.getParent();
607
+ }
608
+ if (!parent || !Node.isPropertyAssignment(parent)) return false;
609
+ const name = getPropertyName(parent);
610
+ if (name && TARGET_KEYS.has(name)) return true;
611
+ // A bare string transition inside `on: { EVENT: '#a.b' }` or
612
+ // `after: { 1000: '#a.b' }`.
613
+ const container = parent.getParent();
614
+ const owner = container?.getParent();
615
+ const ownerName = owner ? getPropertyName(owner) : undefined;
616
+ return ownerName === 'on' || ownerName === 'after';
617
+ }
618
+
619
+ /**
620
+ * Reports (does NOT transform) `#a.b` targets and `matches('#a.b')`/
621
+ * `stateIn('#a.b')` references that the codemod cannot verify as whole state
622
+ * ids. In v6, the text after `#` is an exact id and is never split into an
623
+ * id followed by a path.
624
+ */
625
+ const reportIdPathTargets = {
626
+ name: 'report-id-path-targets',
627
+ description: "Detect (report only) '#id.path' targets and matches('#id.path') references that may combine an id with a relative path, which v6 no longer resolves.",
628
+ apply(sourceFile) {
629
+ const notes = [];
630
+ const filePath = sourceFile.getFilePath();
631
+ const machineConfigs = new Set(findMachineConfigObjects(sourceFile, ['createMachine']));
632
+ const stateConfigs = new Set(findMachineConfigObjects(sourceFile, ['createStateConfig']));
633
+ const idsByConfig = new Map();
634
+ const literals = [...sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral), ...sourceFile.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)].sort((a, b) => a.getStart() - b.getStart());
635
+ for (const literal of literals) {
636
+ const text = literal.getLiteralText();
637
+ if (!text.startsWith('#') || !text.includes('.')) continue;
638
+ if (!isTargetValue(literal) && !isMatchArgument(literal)) continue;
639
+ // Only ids from the config that contains the reference count; a
640
+ // reference outside an analyzable config is always reported.
641
+ const enclosing = findEnclosingConfig(literal, machineConfigs, stateConfigs);
642
+ if (enclosing) {
643
+ let ids = idsByConfig.get(enclosing.config);
644
+ if (!ids) {
645
+ ids = collectStateIds(enclosing.config, enclosing.withImplicitIds);
646
+ idsByConfig.set(enclosing.config, ids);
647
+ }
648
+ if (ids.has(text.slice(1))) continue;
649
+ }
650
+ const {
651
+ line,
652
+ column
653
+ } = sourceFile.getLineAndColumnAtPos(literal.getStart());
654
+ notes.push(`${filePath}:${line}:${column} — '${text}' → '#…' is an exact state id in v6 and is never split into an id and a path; use the target state's own id (its explicit \`id\` or its full implicit id)`);
655
+ }
656
+
657
+ // Never mutates the file.
658
+ return {
659
+ changed: false,
660
+ notes
661
+ };
662
+ }
663
+ };
664
+
518
665
  /** All transforms, in recommended application order. */
519
- const transforms = [renameImports, stringTargets, typesToSchemas, reportRemovedApis];
666
+ const transforms = [renameImports, stringTargets, typesToSchemas, reportRemovedApis, reportIdPathTargets];
520
667
  const transformsByName = Object.fromEntries(transforms.map(t => [t.name, t]));
521
668
 
669
+ /** Report-only transforms whose notes list manual migration steps. */
670
+ const REPORT_TRANSFORMS = new Set(['report-removed-apis', 'report-id-path-targets']);
522
671
  /** Resolve a list of transform names to Transform objects. */
523
672
  function selectTransforms(names) {
524
673
  if (!names || names.length === 0) {
@@ -588,7 +737,7 @@ function run(filePaths, transforms, options = {
588
737
  const sourceFile = project.addSourceFileAtPath(filePath);
589
738
  const report = applyToSourceFile(sourceFile, transforms);
590
739
  for (const pt of report.perTransform) {
591
- if (pt.transform === 'report-removed-apis') {
740
+ if (REPORT_TRANSFORMS.has(pt.transform)) {
592
741
  manualMigrationNotes.push(...pt.notes);
593
742
  }
594
743
  }
@@ -717,4 +866,4 @@ function main(argv) {
717
866
  return 0;
718
867
  }
719
868
 
720
- export { applyToSourceFile, main, parseArgs, renameImports, reportRemovedApis, resolveFiles, run, selectTransforms, stringTargets, transforms, transformsByName, typesToSchemas };
869
+ export { applyToSourceFile, main, parseArgs, renameImports, reportIdPathTargets, reportRemovedApis, resolveFiles, run, selectTransforms, stringTargets, transforms, transformsByName, typesToSchemas };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xstate/codemod",
3
- "version": "0.1.0-alpha.3",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Codemods for migrating XState source between versions (v5 → v6)",
5
5
  "keywords": [
6
6
  "xstate",