@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 +2 -2
- package/dist/declarations/src/index.d.ts +1 -1
- package/dist/declarations/src/runner.d.ts +1 -1
- package/dist/declarations/src/transforms/index.d.ts +2 -1
- package/dist/declarations/src/transforms/report-id-path-targets.d.ts +8 -0
- package/dist/xstate-codemod.js +166 -9
- package/package.json +1 -1
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,
|
|
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
|
|
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;
|
package/dist/xstate-codemod.js
CHANGED
|
@@ -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
|
-
|
|
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(...)`
|
|
460
|
+
stateIn: 'use `self.getSnapshot().matches(...)`',
|
|
450
461
|
fromPromise: 'use `createAsyncLogic(...)`',
|
|
451
|
-
fromTransition: 'use `
|
|
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
|
|
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 };
|