@xstate/codemod 0.1.0-alpha.1 → 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
- Schema helpers use value imports, preserve usable aliases, and avoid conflicting local names. Namespace and type-only imports are supported. Event payload separators and escaped event names retain their meaning. The runner checks transformed syntax before saving; generated code still needs your application's typecheck and tests, and reported manual migrations still need review.
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;
@@ -7,7 +7,13 @@ const RENAMES = {
7
7
  Interpreter: 'Actor',
8
8
  fromCallback: 'createCallbackLogic',
9
9
  fromObservable: 'createObservableLogic',
10
- fromEventObservable: 'createEventObservableLogic'
10
+ fromEventObservable: 'createEventObservableLogic',
11
+ ExecutableActionObject: 'ExecutableEffect',
12
+ ExecutableActionsFrom: 'ExecutableEffectFrom',
13
+ ExecutableSpawnAction: 'SpawnExecutableEffect',
14
+ SpecialExecutableAction: 'SpecialExecutableEffect',
15
+ ToExecutableAction: 'ToExecutableEffect',
16
+ ActorRefFromLogic: 'ActorRefFrom'
11
17
  };
12
18
 
13
19
  /**
@@ -17,7 +23,7 @@ const RENAMES = {
17
23
  */
18
24
  const renameImports = {
19
25
  name: 'rename-imports',
20
- description: "Rename renamed 'xstate' imports (interpret→createActor, Interpreter→Actor, fromCallback/fromObservable/fromEventObservable→create*Logic) 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.",
21
27
  apply(sourceFile) {
22
28
  const notes = [];
23
29
  let changed = false;
@@ -44,7 +50,12 @@ const renameImports = {
44
50
  ref.replaceWithText(replacement);
45
51
  }
46
52
  }
47
- 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
+ }
48
59
  notes.push(`renamed '${importedName}' → '${replacement}'${refs.length ? ` and ${refs.length} usage(s)` : ''}`);
49
60
  }
50
61
  changed = true;
@@ -446,9 +457,12 @@ const REMOVED_APIS = {
446
457
  and: 'inline the guard as `(...) => a(...) && b(...)`',
447
458
  or: 'inline the guard as `(...) => a(...) || b(...)`',
448
459
  not: 'inline the guard as `(...) => !a(...)`',
449
- stateIn: 'use `self.getSnapshot().matches(...)` or `checkStateIn(snapshot, "#id")`',
460
+ stateIn: 'use `self.getSnapshot().matches(...)`',
450
461
  fromPromise: 'use `createAsyncLogic(...)`',
451
- fromTransition: 'use `createLogic({ context, run })`'
462
+ fromTransition: 'use `createStepLogic({ context, run })`',
463
+ toObserver: 'pass an observer object or callback directly',
464
+ pathToStateValue: 'build the state value object directly',
465
+ SpecialTargets: 'use `enq.sendTo(self, event)` for internal sends; parent sends use `enq.sendTo(...)` with the parent ref'
452
466
  };
453
467
 
454
468
  /**
@@ -458,7 +472,7 @@ const REMOVED_APIS = {
458
472
  */
459
473
  const reportRemovedApis = {
460
474
  name: 'report-removed-apis',
461
- description: 'Detect (report only) usages of removed/restructured v5 xstate APIs (assign, raise, sendTo, emit, fromPromise, and/or/not, etc.) that need manual migration.',
475
+ description: 'Detect (report only) usages of removed/restructured v5 xstate APIs (assign, raise, sendTo, emit, fromPromise, and/or/not, toObserver, SpecialTargets, etc.) that need manual migration.',
462
476
  apply(sourceFile) {
463
477
  const notes = [];
464
478
  const xstateImports = sourceFile.getImportDeclarations().filter(imp => imp.getModuleSpecifierValue() === 'xstate');
@@ -507,10 +521,153 @@ const reportRemovedApis = {
507
521
  }
508
522
  };
509
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
+
510
665
  /** All transforms, in recommended application order. */
511
- const transforms = [renameImports, stringTargets, typesToSchemas, reportRemovedApis];
666
+ const transforms = [renameImports, stringTargets, typesToSchemas, reportRemovedApis, reportIdPathTargets];
512
667
  const transformsByName = Object.fromEntries(transforms.map(t => [t.name, t]));
513
668
 
669
+ /** Report-only transforms whose notes list manual migration steps. */
670
+ const REPORT_TRANSFORMS = new Set(['report-removed-apis', 'report-id-path-targets']);
514
671
  /** Resolve a list of transform names to Transform objects. */
515
672
  function selectTransforms(names) {
516
673
  if (!names || names.length === 0) {
@@ -580,7 +737,7 @@ function run(filePaths, transforms, options = {
580
737
  const sourceFile = project.addSourceFileAtPath(filePath);
581
738
  const report = applyToSourceFile(sourceFile, transforms);
582
739
  for (const pt of report.perTransform) {
583
- if (pt.transform === 'report-removed-apis') {
740
+ if (REPORT_TRANSFORMS.has(pt.transform)) {
584
741
  manualMigrationNotes.push(...pt.notes);
585
742
  }
586
743
  }
@@ -709,4 +866,4 @@ function main(argv) {
709
866
  return 0;
710
867
  }
711
868
 
712
- 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.1",
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",