@xstate/codemod 0.1.0-beta.0 → 0.1.0-beta.1
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 -0
- package/bin/alias-hook.mjs +38 -0
- package/bin/codemod.js +10 -2
- package/bin/verify-child.mjs +3 -0
- package/dist/declarations/src/cli.d.ts +3 -1
- package/dist/declarations/src/index.d.ts +2 -1
- package/dist/declarations/src/runner.d.ts +6 -1
- package/dist/declarations/src/transforms/index.d.ts +2 -1
- package/dist/declarations/src/transforms/lower-actions.d.ts +7 -0
- package/dist/declarations/src/transforms/types-to-schemas.d.ts +3 -2
- package/dist/declarations/src/types.d.ts +9 -0
- package/dist/declarations/src/verify/child.d.ts +3 -0
- package/dist/declarations/src/verify/cli.d.ts +2 -0
- package/dist/declarations/src/verify/index.d.ts +4 -0
- package/dist/declarations/src/verify/run.d.ts +37 -0
- package/dist/declarations/src/verify/types.d.ts +177 -0
- package/dist/xstate-codemod.js +4155 -287
- package/package.json +15 -5
- package/skills/migrate-v5-to-v6/SKILL.md +52 -0
package/dist/xstate-codemod.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { Node, SyntaxKind, SymbolFlags, ts, Project } from 'ts-morph';
|
|
2
|
-
import process from 'node:process';
|
|
2
|
+
import process$1 from 'node:process';
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve, join } from 'node:path';
|
|
5
|
+
import { parseArgs as parseArgs$1 } from 'node:util';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { pathToFileURL } from 'node:url';
|
|
3
9
|
|
|
4
10
|
/** Map of v5 xstate exports → v6 replacement names. */
|
|
5
11
|
const RENAMES = {
|
|
@@ -73,7 +79,7 @@ const renameImports = {
|
|
|
73
79
|
* Handles both `createMachine(...)` and `setup(...).createMachine(...)` by
|
|
74
80
|
* returning the trailing property/identifier name (e.g. `createMachine`).
|
|
75
81
|
*/
|
|
76
|
-
function getCallName(call) {
|
|
82
|
+
function getCallName$1(call) {
|
|
77
83
|
const expr = call.getExpression();
|
|
78
84
|
if (Node.isIdentifier(expr)) {
|
|
79
85
|
return expr.getText();
|
|
@@ -96,7 +102,7 @@ function getCallName(call) {
|
|
|
96
102
|
function findMachineConfigObjects(sourceFile, callNames = ['createMachine', 'createStateConfig']) {
|
|
97
103
|
const result = [];
|
|
98
104
|
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
99
|
-
const name = getCallName(call);
|
|
105
|
+
const name = getCallName$1(call);
|
|
100
106
|
if (!name || !callNames.includes(name)) {
|
|
101
107
|
continue;
|
|
102
108
|
}
|
|
@@ -283,52 +289,89 @@ const stringTargets = {
|
|
|
283
289
|
};
|
|
284
290
|
|
|
285
291
|
/**
|
|
286
|
-
*
|
|
287
|
-
*
|
|
292
|
+
* Flattens an events type into `[eventType, payloadTypeText]` entries. Accepts
|
|
293
|
+
* inline object literal members with a string-literal `type`, and references
|
|
294
|
+
* to non-generic type aliases declared in the same file. Returns undefined for
|
|
295
|
+
* any other member (imported types, interfaces, mapped types, ...).
|
|
288
296
|
*/
|
|
289
|
-
function
|
|
290
|
-
if (
|
|
291
|
-
return
|
|
297
|
+
function eventsToEventMap(typeNode, seen = new Set()) {
|
|
298
|
+
if (Node.isParenthesizedTypeNode(typeNode)) {
|
|
299
|
+
return eventsToEventMap(typeNode.getTypeNode(), seen);
|
|
292
300
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
* `[eventType, remainingObjectTypeText]`. Returns undefined when the union is
|
|
300
|
-
* not a literal inline union of object types with string-literal `type` props.
|
|
301
|
-
*/
|
|
302
|
-
function unionToEventMap(typeNode) {
|
|
303
|
-
const members = Node.isUnionTypeNode(typeNode) ? typeNode.getTypeNodes() : [typeNode];
|
|
304
|
-
const entries = [];
|
|
305
|
-
for (const member of members) {
|
|
306
|
-
if (!Node.isTypeLiteral(member)) {
|
|
307
|
-
return undefined; // e.g. a type reference — bail out entirely
|
|
301
|
+
if (Node.isUnionTypeNode(typeNode)) {
|
|
302
|
+
const entries = [];
|
|
303
|
+
for (const member of typeNode.getTypeNodes()) {
|
|
304
|
+
const memberEntries = eventsToEventMap(member, seen);
|
|
305
|
+
if (!memberEntries) return undefined;
|
|
306
|
+
entries.push(...memberEntries);
|
|
308
307
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
308
|
+
return entries;
|
|
309
|
+
}
|
|
310
|
+
if (Node.isTypeReference(typeNode)) {
|
|
311
|
+
const name = typeNode.getTypeName();
|
|
312
|
+
if (!Node.isIdentifier(name) || typeNode.getTypeArguments().length) {
|
|
313
313
|
return undefined;
|
|
314
314
|
}
|
|
315
|
-
const
|
|
316
|
-
|
|
315
|
+
const alias = typeNode.getSourceFile().getTypeAlias(name.getText());
|
|
316
|
+
const aliased = alias?.getTypeNode();
|
|
317
|
+
if (!alias || !aliased || alias.getTypeParameters().length) {
|
|
317
318
|
return undefined;
|
|
318
319
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
320
|
+
if (seen.has(alias.getName())) return undefined;
|
|
321
|
+
return eventsToEventMap(aliased, new Set(seen).add(alias.getName()));
|
|
322
|
+
}
|
|
323
|
+
if (!Node.isTypeLiteral(typeNode)) return undefined;
|
|
324
|
+
const props = typeNode.getProperties();
|
|
325
|
+
if (props.length !== typeNode.getMembers().length) return undefined;
|
|
326
|
+
const typePropTypeNode = props.find(p => p.getName() === 'type')?.getTypeNode();
|
|
327
|
+
if (!typePropTypeNode || !Node.isLiteralTypeNode(typePropTypeNode)) {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
const literal = typePropTypeNode.getLiteral();
|
|
331
|
+
if (!Node.isStringLiteral(literal)) return undefined;
|
|
332
|
+
|
|
333
|
+
// Build payload object type text from the remaining props.
|
|
334
|
+
const payloadProps = props.filter(p => p.getName() !== 'type').map(p => p.getText().replace(/[;,]\s*$/, ''));
|
|
335
|
+
const payload = payloadProps.length ? `{ ${payloadProps.join('; ')} }` : `{}`;
|
|
336
|
+
return [{
|
|
337
|
+
eventType: literal.getLiteralValue(),
|
|
338
|
+
payload
|
|
339
|
+
}];
|
|
340
|
+
}
|
|
322
341
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Reads the members of a `types` initializer, written either as
|
|
344
|
+
* `{} as { context: C; events: E }` or per key as
|
|
345
|
+
* `{ context: {} as C, events: {} as E }`. Returns `'opaque'` for `{} as T`
|
|
346
|
+
* where `T` is not an inline object type, and undefined for any other shape.
|
|
347
|
+
*/
|
|
348
|
+
function getTypesMembers(init) {
|
|
349
|
+
if (Node.isAsExpression(init)) {
|
|
350
|
+
const typeNode = init.getTypeNode();
|
|
351
|
+
if (!typeNode) return undefined;
|
|
352
|
+
if (!Node.isTypeLiteral(typeNode)) return 'opaque';
|
|
353
|
+
return typeNode.getProperties().flatMap(member => {
|
|
354
|
+
const memberType = member.getTypeNode();
|
|
355
|
+
return memberType ? [{
|
|
356
|
+
key: member.getName(),
|
|
357
|
+
typeNode: memberType
|
|
358
|
+
}] : [];
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
if (!Node.isObjectLiteralExpression(init)) return undefined;
|
|
362
|
+
const members = [];
|
|
363
|
+
for (const prop of init.getProperties()) {
|
|
364
|
+
if (!Node.isPropertyAssignment(prop)) return undefined;
|
|
365
|
+
const key = getKey(prop);
|
|
366
|
+
const value = prop.getInitializer();
|
|
367
|
+
const typeNode = value && Node.isAsExpression(value) ? value.getTypeNode() : undefined;
|
|
368
|
+
if (!key || !typeNode) return undefined;
|
|
369
|
+
members.push({
|
|
370
|
+
key,
|
|
371
|
+
typeNode
|
|
329
372
|
});
|
|
330
373
|
}
|
|
331
|
-
return
|
|
374
|
+
return members;
|
|
332
375
|
}
|
|
333
376
|
function getKey(prop) {
|
|
334
377
|
const nameNode = prop.getNameNode();
|
|
@@ -340,91 +383,109 @@ function getKey(prop) {
|
|
|
340
383
|
}
|
|
341
384
|
return undefined;
|
|
342
385
|
}
|
|
386
|
+
const LEFTOVER_TYPES_HINT = '`types` was replaced by `schemas`; declare events as a map `schemas: { events: { TYPE: types<Payload>() } }`, or use `types<EventUnion>()` when the union is not inline.';
|
|
387
|
+
const EVENTS_FALLBACK_HINT = '`events: types<E>()` loads but does not type-check as a v6 event map; expand `E` into `events: { TYPE: types<Payload>(), ... }`.';
|
|
388
|
+
|
|
389
|
+
/** Records a manual site for a `types` property left at `pos`. */
|
|
390
|
+
function pushLeftoverSite(sourceFile, pos, sites) {
|
|
391
|
+
const {
|
|
392
|
+
line,
|
|
393
|
+
column
|
|
394
|
+
} = sourceFile.getLineAndColumnAtPos(pos);
|
|
395
|
+
sites.push({
|
|
396
|
+
file: sourceFile.getFilePath(),
|
|
397
|
+
line,
|
|
398
|
+
column,
|
|
399
|
+
api: 'types',
|
|
400
|
+
hint: LEFTOVER_TYPES_HINT
|
|
401
|
+
});
|
|
402
|
+
}
|
|
343
403
|
|
|
344
404
|
/**
|
|
345
|
-
* Converts a single `types
|
|
346
|
-
*
|
|
405
|
+
* Converts a single `types` property into a `schemas: { ... }` property.
|
|
406
|
+
* Mutates the containing config object literal.
|
|
347
407
|
*/
|
|
348
|
-
function convertTypesProperty(configObj, typesProp, notes) {
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
408
|
+
function convertTypesProperty(configObj, typesProp, notes, sites, getHelper) {
|
|
409
|
+
const sourceFile = configObj.getSourceFile();
|
|
410
|
+
const start = typesProp.getStart();
|
|
411
|
+
const members = getTypesMembers(typesProp.getInitializerOrThrow());
|
|
412
|
+
if (!members) {
|
|
413
|
+
pushLeftoverSite(sourceFile, start, sites);
|
|
352
414
|
return false;
|
|
353
415
|
}
|
|
354
416
|
|
|
355
417
|
// `types: {} as any` (or non-object) — cannot infer a shape; leave a note.
|
|
356
|
-
if (
|
|
357
|
-
notes.push(`left 'types' unchanged: initializer is not an inline object type (
|
|
418
|
+
if (members === 'opaque') {
|
|
419
|
+
notes.push(`left 'types' unchanged: initializer is not an inline object type (${typesProp.getInitializerOrThrow().getText()})`);
|
|
420
|
+
pushLeftoverSite(sourceFile, start, sites);
|
|
358
421
|
typesProp.replaceWithText(`${typesProp.getText()} /* TODO(xstate-codemod): could not migrate 'types' to 'schemas' automatically */`);
|
|
359
422
|
return false;
|
|
360
423
|
}
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
let usedTypes = false;
|
|
364
|
-
let eventsLeftBehind = false;
|
|
365
|
-
for (const member of lit.getProperties()) {
|
|
366
|
-
const key = member.getName();
|
|
367
|
-
const valueTypeNode = member.getTypeNode();
|
|
368
|
-
if (!valueTypeNode) {
|
|
369
|
-
continue;
|
|
370
|
-
}
|
|
371
|
-
if (key === 'events') {
|
|
372
|
-
const map = unionToEventMap(valueTypeNode);
|
|
373
|
-
if (!map) {
|
|
374
|
-
// Not a literal inline union — leave events in `types`, add a TODO.
|
|
375
|
-
eventsLeftBehind = true;
|
|
376
|
-
notes.push(`could not migrate 'events' to schemas.events map (not a literal inline union); left in place`);
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
schemaEntries.push(helper => {
|
|
380
|
-
const mapEntries = map.map(e => `${JSON.stringify(e.eventType)}: ${helper}<${e.payload}>()`);
|
|
381
|
-
return `events: {\n ${mapEntries.join(',\n ')}\n }`;
|
|
382
|
-
});
|
|
383
|
-
usedTypes = true;
|
|
384
|
-
} else {
|
|
385
|
-
// context, input, output, emitted, tags, meta, ... → types<T>()
|
|
386
|
-
const valueType = valueTypeNode.getText();
|
|
387
|
-
schemaEntries.push(helper => `${key}: ${helper}<${valueType}>()`);
|
|
388
|
-
usedTypes = true;
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
if (schemaEntries.length === 0) {
|
|
392
|
-
return false;
|
|
393
|
-
}
|
|
394
|
-
const helper = usedTypes ? ensureNamedImport(configObj.getSourceFile(), 'types', configObj) : undefined;
|
|
424
|
+
if (members.length === 0) return false;
|
|
425
|
+
const helper = getHelper(configObj);
|
|
395
426
|
if (!helper) {
|
|
396
427
|
notes.push("left 'types' unchanged: no xstate runtime import available");
|
|
428
|
+
pushLeftoverSite(sourceFile, start, sites);
|
|
397
429
|
return false;
|
|
398
430
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
431
|
+
let eventsFallback = false;
|
|
432
|
+
const schemaEntries = members.map(({
|
|
433
|
+
key,
|
|
434
|
+
typeNode
|
|
435
|
+
}) => {
|
|
436
|
+
if (key === 'events') {
|
|
437
|
+
const map = eventsToEventMap(typeNode);
|
|
438
|
+
if (map) {
|
|
439
|
+
const mapEntries = map.map(e => `${JSON.stringify(e.eventType)}: ${helper}<${e.payload}>()`);
|
|
440
|
+
return `events: {\n ${mapEntries.join(',\n ')}\n }`;
|
|
441
|
+
}
|
|
442
|
+
notes.push(`'events' is not an inline literal union or a same-file alias of one; wrote it as a single ${helper}<...>() schema`);
|
|
443
|
+
eventsFallback = true;
|
|
444
|
+
}
|
|
445
|
+
// context, input, output, emitted, tags, meta, ... → types<T>()
|
|
446
|
+
return `${key}: ${helper}<${typeNode.getText()}>()`;
|
|
447
|
+
});
|
|
448
|
+
typesProp.replaceWithText(`schemas: {\n ${schemaEntries.join(',\n ')}\n}`);
|
|
449
|
+
if (eventsFallback) {
|
|
450
|
+
// `events: types<E>()` loads but is not a valid v6 event map type.
|
|
451
|
+
const schemas = configObj.getProperty('schemas');
|
|
452
|
+
const events = schemas && Node.isPropertyAssignment(schemas) ? schemas.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression)?.getProperty('events') : undefined;
|
|
453
|
+
const {
|
|
454
|
+
line,
|
|
455
|
+
column
|
|
456
|
+
} = sourceFile.getLineAndColumnAtPos((events ?? schemas ?? configObj).getStart());
|
|
457
|
+
sites.push({
|
|
458
|
+
file: sourceFile.getFilePath(),
|
|
459
|
+
line,
|
|
460
|
+
column,
|
|
461
|
+
api: 'types',
|
|
462
|
+
hint: EVENTS_FALLBACK_HINT
|
|
463
|
+
});
|
|
409
464
|
}
|
|
410
|
-
notes.push(`converted 'types' → 'schemas' (${schemaEntries.length} key(s))
|
|
465
|
+
notes.push(`converted 'types' → 'schemas' (${schemaEntries.length} key(s))`);
|
|
411
466
|
return true;
|
|
412
467
|
}
|
|
413
468
|
|
|
414
469
|
/**
|
|
415
|
-
* Converts `types: {} as { context; events; input; ... }`
|
|
416
|
-
*
|
|
470
|
+
* Converts `types: {} as { context; events; input; ... }` (or the per-key
|
|
471
|
+
* `types: { context: {} as C, ... }` form) inside machine config objects into
|
|
472
|
+
* `schemas: { context: types<...>(), ... }`.
|
|
417
473
|
*/
|
|
418
474
|
const typesToSchemas = {
|
|
419
475
|
name: 'types-to-schemas',
|
|
420
|
-
description: 'Convert `types: {} as {...}` into `schemas: { context: types<C>(), events: { ... }, input: types<I>() }`. Events
|
|
476
|
+
description: 'Convert `types: {} as {...}` and `types: { context: {} as C, ... }` into `schemas: { context: types<C>(), events: { ... }, input: types<I>() }`. Events become a map when written as a literal union, inline or in a same-file type alias; otherwise `events: types<E>()`.',
|
|
421
477
|
apply(sourceFile) {
|
|
422
478
|
const notes = [];
|
|
479
|
+
const sites = [];
|
|
423
480
|
let changed = false;
|
|
481
|
+
// `ensureNamedImport` reads stale scope after adding an import, so resolve
|
|
482
|
+
// the helper binding once per file.
|
|
483
|
+
let helper;
|
|
484
|
+
const getHelper = location => helper ??= ensureNamedImport(sourceFile, 'types', location);
|
|
424
485
|
for (const configObj of findMachineConfigObjects(sourceFile, ['createMachine', 'createStateConfig', 'setup'])) {
|
|
425
486
|
for (const prop of configObj.getProperties()) {
|
|
426
487
|
if (Node.isPropertyAssignment(prop) && getKey(prop) === 'types') {
|
|
427
|
-
if (convertTypesProperty(configObj, prop, notes)) {
|
|
488
|
+
if (convertTypesProperty(configObj, prop, notes, sites, getHelper)) {
|
|
428
489
|
changed = true;
|
|
429
490
|
}
|
|
430
491
|
}
|
|
@@ -432,206 +493,2313 @@ const typesToSchemas = {
|
|
|
432
493
|
}
|
|
433
494
|
return {
|
|
434
495
|
changed,
|
|
435
|
-
notes
|
|
496
|
+
notes,
|
|
497
|
+
sites
|
|
436
498
|
};
|
|
437
499
|
}
|
|
438
500
|
};
|
|
439
501
|
|
|
440
|
-
/**
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const REMOVED_APIS = {
|
|
445
|
-
assign: 'return a `{ context }` patch from the transition/action function',
|
|
446
|
-
raise: 'use `enq.raise(...)` inside an action',
|
|
447
|
-
sendTo: 'use `enq.sendTo(...)` inside an action',
|
|
448
|
-
sendParent: 'use `enq.sendTo(...)` with the parent ref inside an action',
|
|
449
|
-
forwardTo: 'use `enq.sendTo(target, event)` inside an action',
|
|
450
|
-
emit: 'use `enq.emit(...)` inside an action',
|
|
451
|
-
log: 'use `enq.log(...)` inside an action',
|
|
452
|
-
cancel: 'use `enq.cancel(...)` inside an action',
|
|
453
|
-
spawnChild: 'use `enq.spawn(...)` inside an action',
|
|
454
|
-
stop: 'use `enq.stop(...)` inside an action',
|
|
455
|
-
stopChild: 'use `enq.stop(...)` inside an action',
|
|
456
|
-
enqueueActions: 'action functions now receive an `enq` enqueue object directly',
|
|
457
|
-
and: 'inline the guard as `(...) => a(...) && b(...)`',
|
|
458
|
-
or: 'inline the guard as `(...) => a(...) || b(...)`',
|
|
459
|
-
not: 'inline the guard as `(...) => !a(...)`',
|
|
460
|
-
stateIn: 'use `self.getSnapshot().matches(...)`',
|
|
461
|
-
fromPromise: 'use `createAsyncLogic(...)`',
|
|
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'
|
|
502
|
+
/** Thrown to abandon lowering of one node; the report transforms then flag it. */
|
|
503
|
+
class Skip extends Error {}
|
|
504
|
+
const skip = reason => {
|
|
505
|
+
throw new Skip(reason);
|
|
466
506
|
};
|
|
467
507
|
|
|
468
|
-
/**
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const notes = [];
|
|
478
|
-
const xstateImports = sourceFile.getImportDeclarations().filter(imp => imp.getModuleSpecifierValue() === 'xstate');
|
|
479
|
-
|
|
480
|
-
// Map local binding name → canonical API name (respecting aliases).
|
|
481
|
-
const tracked = new Map();
|
|
482
|
-
for (const imp of xstateImports) {
|
|
483
|
-
for (const spec of imp.getNamedImports()) {
|
|
484
|
-
const original = spec.getNameNode().getText();
|
|
485
|
-
if (REMOVED_APIS[original]) {
|
|
486
|
-
const local = (spec.getAliasNode() ?? spec.getNameNode()).getText();
|
|
487
|
-
tracked.set(local, original);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
508
|
+
/** Local name → imported name, for value imports from `'xstate'`. */
|
|
509
|
+
function getXstateImports(sourceFile) {
|
|
510
|
+
const map = new Map();
|
|
511
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
512
|
+
if (imp.getModuleSpecifierValue() !== 'xstate' || imp.isTypeOnly()) continue;
|
|
513
|
+
for (const spec of imp.getNamedImports()) {
|
|
514
|
+
if (spec.isTypeOnly()) continue;
|
|
515
|
+
const local = (spec.getAliasNode() ?? spec.getNameNode()).getText();
|
|
516
|
+
map.set(local, spec.getName());
|
|
490
517
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
518
|
+
}
|
|
519
|
+
return map;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The `'xstate'` export a call invokes, e.g. `assign` for `assign(...)`. */
|
|
523
|
+
function xstateCallee(node, xs) {
|
|
524
|
+
if (!Node.isCallExpression(node)) return undefined;
|
|
525
|
+
const expr = node.getExpression();
|
|
526
|
+
return Node.isIdentifier(expr) ? xs.get(expr.getText()) : undefined;
|
|
527
|
+
}
|
|
528
|
+
function getKeyText$2(node) {
|
|
529
|
+
if (!Node.isPropertyAssignment(node) && !Node.isMethodDeclaration(node) && !Node.isShorthandPropertyAssignment(node)) return undefined;
|
|
530
|
+
const name = node.getNameNode();
|
|
531
|
+
if (Node.isStringLiteral(name) || Node.isNoSubstitutionTemplateLiteral(name)) return name.getLiteralText();
|
|
532
|
+
if (Node.isIdentifier(name) || Node.isNumericLiteral(name)) return name.getText();
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
function getProperty$1(object, key) {
|
|
536
|
+
if (!Node.isObjectLiteralExpression(object)) return undefined;
|
|
537
|
+
const property = object.getProperties().find(candidate => getKeyText$2(candidate) === key);
|
|
538
|
+
return Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
539
|
+
}
|
|
540
|
+
function isFunctionLike(node) {
|
|
541
|
+
return !!node && (Node.isArrowFunction(node) || Node.isFunctionExpression(node));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `.name` or `['na me']`. */
|
|
545
|
+
function access(name) {
|
|
546
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
|
|
547
|
+
}
|
|
548
|
+
/** Guard combinators a named guard may be defined as, inlined at call sites. */
|
|
549
|
+
const INLINE_GUARDS = new Set(['and', 'or', 'not', 'stateIn']);
|
|
550
|
+
function classify$1(property, xs, group) {
|
|
551
|
+
if (Node.isMethodDeclaration(property)) return {
|
|
552
|
+
kind: 'fn',
|
|
553
|
+
property
|
|
554
|
+
};
|
|
555
|
+
if (Node.isShorthandPropertyAssignment(property)) {
|
|
556
|
+
if (group === 'actions') {
|
|
557
|
+
const constAssign = implConstAssign(property.getNameNode(), xs);
|
|
558
|
+
if (constAssign) return {
|
|
559
|
+
kind: 'assign',
|
|
560
|
+
property,
|
|
561
|
+
...constAssign
|
|
495
562
|
};
|
|
496
563
|
}
|
|
497
|
-
const filePath = sourceFile.getFilePath();
|
|
498
|
-
for (const ident of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
499
|
-
const local = ident.getText();
|
|
500
|
-
const api = tracked.get(local);
|
|
501
|
-
if (!api) {
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
// Skip the import specifier declarations themselves.
|
|
505
|
-
const parentKind = ident.getParent()?.getKind();
|
|
506
|
-
if (parentKind === SyntaxKind.ImportSpecifier || parentKind === SyntaxKind.ImportClause) {
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
const {
|
|
510
|
-
line,
|
|
511
|
-
column
|
|
512
|
-
} = sourceFile.getLineAndColumnAtPos(ident.getPos());
|
|
513
|
-
notes.push(`${filePath}:${line}:${column} — '${api}' → ${REMOVED_APIS[api]}`);
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Never mutates the file.
|
|
517
564
|
return {
|
|
518
|
-
|
|
519
|
-
|
|
565
|
+
kind: resolveIdentifier(property.getNameNode()) === 'fn' ? 'fn' : 'other',
|
|
566
|
+
property
|
|
520
567
|
};
|
|
521
568
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
569
|
+
if (!Node.isPropertyAssignment(property)) return {
|
|
570
|
+
kind: 'other',
|
|
571
|
+
property
|
|
572
|
+
};
|
|
573
|
+
const value = unwrap(property.getInitializer());
|
|
574
|
+
if (isFunctionLike(value)) return {
|
|
575
|
+
kind: 'fn',
|
|
576
|
+
property
|
|
577
|
+
};
|
|
578
|
+
if (xstateCallee(value, xs) === 'assign') {
|
|
579
|
+
const arg = value.getArguments()[0];
|
|
580
|
+
if (arg) return {
|
|
581
|
+
kind: 'assign',
|
|
582
|
+
property,
|
|
583
|
+
assignArg: arg
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
if (Node.isIdentifier(value) && resolveIdentifier(value) === 'fn') {
|
|
587
|
+
return {
|
|
588
|
+
kind: 'fn',
|
|
589
|
+
property
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
if (group === 'actions' && Node.isIdentifier(value)) {
|
|
593
|
+
const constAssign = implConstAssign(value, xs);
|
|
594
|
+
if (constAssign) return {
|
|
595
|
+
kind: 'assign',
|
|
596
|
+
property,
|
|
597
|
+
...constAssign
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const callee = xstateCallee(value, xs);
|
|
601
|
+
if (callee && (group === 'guards' ? INLINE_GUARDS.has(callee) : ACTION_CREATORS.has(callee) || callee === 'enqueueActions')) {
|
|
602
|
+
return {
|
|
603
|
+
kind: 'inline',
|
|
604
|
+
property,
|
|
605
|
+
expr: value
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
kind: 'other',
|
|
610
|
+
property
|
|
611
|
+
};
|
|
612
|
+
}
|
|
526
613
|
|
|
527
|
-
/**
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
614
|
+
/** Strips `as` / `satisfies` / parentheses. */
|
|
615
|
+
function unwrap(node) {
|
|
616
|
+
let current = node;
|
|
617
|
+
while (Node.isAsExpression(current) || Node.isSatisfiesExpression(current) || Node.isParenthesizedExpression(current)) {
|
|
618
|
+
current = current.getExpression();
|
|
619
|
+
}
|
|
620
|
+
return current;
|
|
621
|
+
}
|
|
622
|
+
function collectFrom(impls, object, xs) {
|
|
623
|
+
if (!object || !Node.isObjectLiteralExpression(object)) return;
|
|
624
|
+
for (const group of ['actions', 'guards']) {
|
|
625
|
+
const map = getProperty$1(object, group);
|
|
626
|
+
if (!map || !Node.isObjectLiteralExpression(map)) continue;
|
|
627
|
+
for (const property of map.getProperties()) {
|
|
628
|
+
const key = implKey(property);
|
|
629
|
+
if (key === undefined || impls[group].has(key)) continue;
|
|
630
|
+
impls[group].set(key, classify$1(property, xs, group));
|
|
534
631
|
}
|
|
535
632
|
}
|
|
536
|
-
return undefined;
|
|
537
633
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
634
|
+
|
|
635
|
+
/** Finds the `setup({...})` argument a `createMachine`/`createStateConfig` call is chained to. */
|
|
636
|
+
function findSetupArgument(config) {
|
|
637
|
+
const call = config.getParent();
|
|
638
|
+
if (!Node.isCallExpression(call)) return undefined;
|
|
639
|
+
const expr = call.getExpression();
|
|
640
|
+
if (!Node.isPropertyAccessExpression(expr)) return undefined;
|
|
641
|
+
let receiver = expr.getExpression();
|
|
642
|
+
if (Node.isIdentifier(receiver)) {
|
|
643
|
+
const decl = receiver.getSymbol()?.getDeclarations().find(Node.isVariableDeclaration);
|
|
644
|
+
const init = decl?.getInitializer();
|
|
645
|
+
if (!init) return undefined;
|
|
646
|
+
receiver = init;
|
|
647
|
+
}
|
|
648
|
+
if (!Node.isCallExpression(receiver)) return undefined;
|
|
649
|
+
const callee = receiver.getExpression();
|
|
650
|
+
if (!Node.isIdentifier(callee) || callee.getText() !== 'setup') return undefined;
|
|
651
|
+
return receiver.getArguments()[0];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Named implementations visible to a machine config: its own top-level keys, then `setup`. */
|
|
655
|
+
function collectImplementations(config, xs) {
|
|
656
|
+
const impls = {
|
|
657
|
+
actions: new Map(),
|
|
658
|
+
guards: new Map()
|
|
659
|
+
};
|
|
660
|
+
collectFrom(impls, config, xs);
|
|
661
|
+
collectFrom(impls, findSetupArgument(config), xs);
|
|
662
|
+
return impls;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** What an identifier's in-file declaration is: a function, a plain value, or unknown. */
|
|
666
|
+
function resolveIdentifier(id) {
|
|
667
|
+
const parent = id.getParent();
|
|
668
|
+
const symbol = Node.isShorthandPropertyAssignment(parent) ? parent.getValueSymbol() : id.getSymbol();
|
|
669
|
+
const decls = symbol?.getDeclarations() ?? [];
|
|
670
|
+
if (decls.length !== 1) return 'unknown';
|
|
671
|
+
const decl = decls[0];
|
|
672
|
+
if (Node.isFunctionDeclaration(decl)) return 'fn';
|
|
673
|
+
if (!Node.isVariableDeclaration(decl)) return 'unknown';
|
|
674
|
+
const list = decl.getParent();
|
|
675
|
+
if (Node.isVariableDeclarationList(list) && !(list.getFlags() & 2) /* NodeFlags.Const */) return 'unknown';
|
|
676
|
+
const init = decl.getInitializer();
|
|
677
|
+
if (!init) return 'unknown';
|
|
678
|
+
const value = unwrap(init);
|
|
679
|
+
if (isFunctionLike(value)) return 'fn';
|
|
680
|
+
if (Node.isObjectLiteralExpression(value) || Node.isArrayLiteralExpression(value) || Node.isStringLiteral(value) || Node.isNumericLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) || Node.isTemplateExpression(value)) return 'value';
|
|
681
|
+
return 'unknown';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Initializer of an identifier's single `const` declaration in this file. */
|
|
685
|
+
function resolveConstInitializer(id) {
|
|
686
|
+
const decls = id.getSymbol()?.getDeclarations() ?? [];
|
|
687
|
+
if (decls.length !== 1 || !Node.isVariableDeclaration(decls[0])) return undefined;
|
|
688
|
+
const init = decls[0].getInitializer();
|
|
689
|
+
return init && unwrap(init);
|
|
543
690
|
}
|
|
691
|
+
function isReferenceIdentifier(id) {
|
|
692
|
+
const parent = id.getParent();
|
|
693
|
+
if (!parent) return false;
|
|
694
|
+
if ((Node.isPropertyAssignment(parent) || Node.isPropertySignature(parent) || Node.isMethodDeclaration(parent) || Node.isPropertyDeclaration(parent)) && parent.getNameNode() === id) return false;
|
|
695
|
+
if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === id) return false;
|
|
696
|
+
if (Node.isBindingElement(parent) && parent.getPropertyNameNode() === id) return false;
|
|
697
|
+
if (Node.isQualifiedName(parent) && parent.getRight() === id) return false;
|
|
698
|
+
if (Node.isTypeReference(parent)) return false;
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
544
702
|
/**
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
* are not state ids and are not collected.
|
|
703
|
+
* Names in `names` that `node` references while declared outside it (or not
|
|
704
|
+
* at all, such as globals). Generated bindings with these names would capture
|
|
705
|
+
* those references.
|
|
549
706
|
*/
|
|
550
|
-
function
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
if (!
|
|
557
|
-
const
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
const child = state.getInitializer();
|
|
563
|
-
if (!child || !Node.isObjectLiteralExpression(child)) continue;
|
|
564
|
-
walk(child, implicitId ? `${implicitId}.${key}` : key);
|
|
707
|
+
function outerReferences(node, names) {
|
|
708
|
+
const found = new Set();
|
|
709
|
+
const start = node.getPos();
|
|
710
|
+
const end = node.getEnd();
|
|
711
|
+
for (const id of node.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
712
|
+
const text = id.getText();
|
|
713
|
+
if (!names.has(text) || !isReferenceIdentifier(id)) continue;
|
|
714
|
+
const parent = id.getParent();
|
|
715
|
+
const symbol = Node.isShorthandPropertyAssignment(parent) ? id.getProject().getTypeChecker().compilerObject.getShorthandAssignmentValueSymbol(parent.compilerNode) : id.getSymbol()?.compilerSymbol;
|
|
716
|
+
const decls = symbol?.declarations ?? [];
|
|
717
|
+
if (!decls.length || decls.some(decl => decl.pos < start || decl.end > end)) {
|
|
718
|
+
found.add(text);
|
|
565
719
|
}
|
|
566
720
|
}
|
|
567
|
-
|
|
568
|
-
|
|
721
|
+
return found;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Key of an implementation map entry, resolving `[Enum.X]` computed keys. */
|
|
725
|
+
function implKey(property) {
|
|
726
|
+
if ((Node.isPropertyAssignment(property) || Node.isMethodDeclaration(property)) && Node.isComputedPropertyName(property.getNameNode())) {
|
|
727
|
+
const name = property.getNameNode();
|
|
728
|
+
return Node.isComputedPropertyName(name) ? resolveStringConstant(name.getExpression()) : undefined;
|
|
729
|
+
}
|
|
730
|
+
return getKeyText$2(property);
|
|
569
731
|
}
|
|
570
732
|
|
|
571
733
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
734
|
+
* The string an expression always evaluates to: a string literal, a `const`
|
|
735
|
+
* bound to one, a member of a string enum, or a property of a `const` object
|
|
736
|
+
* literal declared in this file.
|
|
574
737
|
*/
|
|
575
|
-
function
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return {
|
|
586
|
-
config: ancestor,
|
|
587
|
-
withImplicitIds: false
|
|
588
|
-
};
|
|
589
|
-
}
|
|
738
|
+
function resolveStringConstant(node) {
|
|
739
|
+
const value = unwrap(node);
|
|
740
|
+
if (Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value)) return value.getLiteralText();
|
|
741
|
+
if (Node.isIdentifier(value)) {
|
|
742
|
+
const decls = value.getSymbol()?.getDeclarations() ?? [];
|
|
743
|
+
if (decls.length !== 1 || !Node.isVariableDeclaration(decls[0])) return;
|
|
744
|
+
if (!isConst(decls[0])) return;
|
|
745
|
+
const init = decls[0].getInitializer();
|
|
746
|
+
const literal = init && unwrap(init);
|
|
747
|
+
return literal && Node.isStringLiteral(literal) ? literal.getLiteralText() : undefined;
|
|
590
748
|
}
|
|
591
|
-
return undefined;
|
|
749
|
+
if (!Node.isPropertyAccessExpression(value)) return undefined;
|
|
750
|
+
const object = value.getExpression();
|
|
751
|
+
if (!Node.isIdentifier(object)) return undefined;
|
|
752
|
+
const member = value.getName();
|
|
753
|
+
const decls = object.getSymbol()?.getDeclarations() ?? [];
|
|
754
|
+
if (decls.length !== 1) return undefined;
|
|
755
|
+
const decl = decls[0];
|
|
756
|
+
if (Node.isEnumDeclaration(decl)) {
|
|
757
|
+
const m = decl.getMember(member);
|
|
758
|
+
const init = m?.getInitializer();
|
|
759
|
+
return init && Node.isStringLiteral(init) ? init.getLiteralText() : undefined;
|
|
760
|
+
}
|
|
761
|
+
if (!Node.isVariableDeclaration(decl) || !isConst(decl)) return undefined;
|
|
762
|
+
const init = decl.getInitializer();
|
|
763
|
+
const literal = init && unwrap(init);
|
|
764
|
+
if (!literal || !Node.isObjectLiteralExpression(literal)) return undefined;
|
|
765
|
+
const property = literal.getProperties().find(candidate => getKeyText$2(candidate) === member);
|
|
766
|
+
if (!Node.isPropertyAssignment(property)) return undefined;
|
|
767
|
+
const v = unwrap(property.getInitializer());
|
|
768
|
+
return Node.isStringLiteral(v) || Node.isNoSubstitutionTemplateLiteral(v) ? v.getLiteralText() : undefined;
|
|
592
769
|
}
|
|
593
|
-
function
|
|
594
|
-
const
|
|
595
|
-
|
|
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);
|
|
770
|
+
function isConst(decl) {
|
|
771
|
+
const list = decl.getParent();
|
|
772
|
+
return Node.isVariableDeclarationList(list) && !!(list.getFlags() & 2) /* NodeFlags.Const */;
|
|
600
773
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Free identifiers in `node` that are declared inside a function or block
|
|
777
|
+
* other than the module (so inlining the node elsewhere could not see them).
|
|
778
|
+
*/
|
|
779
|
+
function nonModuleReferences(node) {
|
|
780
|
+
const found = new Set();
|
|
781
|
+
const start = node.getPos();
|
|
782
|
+
const end = node.getEnd();
|
|
783
|
+
for (const id of node.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
784
|
+
if (!isReferenceIdentifier(id)) continue;
|
|
785
|
+
const parent = id.getParent();
|
|
786
|
+
const symbol = Node.isShorthandPropertyAssignment(parent) ? id.getProject().getTypeChecker().compilerObject.getShorthandAssignmentValueSymbol(parent.compilerNode) : id.getSymbol()?.compilerSymbol;
|
|
787
|
+
for (const decl of symbol?.declarations ?? []) {
|
|
788
|
+
if (decl.pos >= start && decl.end <= end) continue;
|
|
789
|
+
if (decl.getSourceFile() !== node.getSourceFile().compilerNode) continue;
|
|
790
|
+
let scope = decl.parent;
|
|
791
|
+
while (scope && !ts.isSourceFile(scope)) {
|
|
792
|
+
if (ts.isFunctionLike(scope) || ts.isBlock(scope) || ts.isClassLike(scope) || ts.isModuleBlock(scope)) {
|
|
793
|
+
found.add(id.getText());
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
scope = scope.parent;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
607
799
|
}
|
|
608
|
-
|
|
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';
|
|
800
|
+
return [...found];
|
|
617
801
|
}
|
|
618
802
|
|
|
803
|
+
/** Action creators (besides `assign`) that lower to `enq` statements. */
|
|
804
|
+
const ACTION_CREATORS = new Set(['raise', 'sendTo', 'sendParent', 'forwardTo', 'emit', 'log', 'cancel', 'stopChild', 'stop', 'spawnChild']);
|
|
619
805
|
/**
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
* ids. In v6, the text after `#` is an exact id and is never split into an
|
|
623
|
-
* id followed by a path.
|
|
806
|
+
* `const x = assign(...)` (or another action creator) declared at module
|
|
807
|
+
* level and not exported, for an identifier used as an action.
|
|
624
808
|
*/
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
809
|
+
function resolveConstAction(id, xs) {
|
|
810
|
+
const decls = id.getSymbol()?.getDeclarations() ?? [];
|
|
811
|
+
if (decls.length !== 1) return undefined;
|
|
812
|
+
const decl = decls[0];
|
|
813
|
+
if (!Node.isVariableDeclaration(decl) || !isConst(decl)) return undefined;
|
|
814
|
+
const statement = decl.getVariableStatement();
|
|
815
|
+
if (!statement || !Node.isSourceFile(statement.getParent()) || statement.isExported() || statement.getDeclarations().length !== 1) return undefined;
|
|
816
|
+
const init = decl.getInitializer();
|
|
817
|
+
const call = init && unwrap(init);
|
|
818
|
+
const callee = call && xstateCallee(call, xs);
|
|
819
|
+
if (!callee || !(callee === 'assign' || callee === 'enqueueActions' || ACTION_CREATORS.has(callee))) return undefined;
|
|
820
|
+
return {
|
|
821
|
+
decl,
|
|
822
|
+
call: call,
|
|
823
|
+
callee
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Whether a config value is a reference rather than written inline. */
|
|
828
|
+
function isIndirect(node) {
|
|
829
|
+
const value = unwrap(node);
|
|
830
|
+
return Node.isIdentifier(value) && value.getText() !== 'undefined' || Node.isPropertyAccessExpression(value) || Node.isElementAccessExpression(value) || Node.isShorthandPropertyAssignment(value) || Node.isSpreadAssignment(value) || Node.isSpreadElement(value);
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Resolves a config reference (identifier, shorthand property or spread) to
|
|
834
|
+
* the object/array literal or function of a module-level `const` in this file.
|
|
835
|
+
*/
|
|
836
|
+
function derefConfig(node) {
|
|
837
|
+
let value = unwrap(node);
|
|
838
|
+
let symbol;
|
|
839
|
+
if (Node.isShorthandPropertyAssignment(value)) {
|
|
840
|
+
symbol = value.getValueSymbol();
|
|
841
|
+
} else {
|
|
842
|
+
if (Node.isSpreadAssignment(value) || Node.isSpreadElement(value)) {
|
|
843
|
+
value = unwrap(value.getExpression());
|
|
844
|
+
}
|
|
845
|
+
if (!Node.isIdentifier(value)) return undefined;
|
|
846
|
+
symbol = value.getSymbol();
|
|
847
|
+
}
|
|
848
|
+
let decl;
|
|
849
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
850
|
+
const decls = symbol?.getDeclarations() ?? [];
|
|
851
|
+
if (decls.length !== 1) return undefined;
|
|
852
|
+
const d = decls[0];
|
|
853
|
+
if (Node.isFunctionDeclaration(d)) return {
|
|
854
|
+
node: d,
|
|
855
|
+
decl: decl ?? d
|
|
856
|
+
};
|
|
857
|
+
if (!Node.isVariableDeclaration(d) || !isConst(d)) return undefined;
|
|
858
|
+
const statement = d.getVariableStatement();
|
|
859
|
+
if (!statement || !Node.isSourceFile(statement.getParent())) return undefined;
|
|
860
|
+
const init = d.getInitializer();
|
|
861
|
+
if (!init) return undefined;
|
|
862
|
+
decl ??= d;
|
|
863
|
+
value = unwrap(init);
|
|
864
|
+
if (!Node.isIdentifier(value)) break;
|
|
865
|
+
symbol = value.getSymbol();
|
|
866
|
+
}
|
|
867
|
+
if (Node.isObjectLiteralExpression(value) || Node.isArrayLiteralExpression(value) || isFunctionLike(value)) return {
|
|
868
|
+
node: value,
|
|
869
|
+
decl: decl
|
|
870
|
+
};
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Whether every reference to a module const sits where a config value or a
|
|
876
|
+
* spread of one goes, so rewriting its literal in place is safe.
|
|
877
|
+
*/
|
|
878
|
+
function onlyConfigReferences(decl) {
|
|
879
|
+
if (!Node.isVariableDeclaration(decl)) return false;
|
|
880
|
+
const name = decl.getNameNode();
|
|
881
|
+
const symbol = name.getSymbol();
|
|
882
|
+
return decl.getSourceFile().getDescendantsOfKind(SyntaxKind.Identifier).filter(id => {
|
|
883
|
+
if (id === name || id.getText() !== name.getText()) return false;
|
|
884
|
+
const parent = id.getParent();
|
|
885
|
+
return (Node.isShorthandPropertyAssignment(parent) ? parent.getValueSymbol() : id.getSymbol()) === symbol;
|
|
886
|
+
}).every(id => {
|
|
887
|
+
const parent = id.getParent();
|
|
888
|
+
return Node.isShorthandPropertyAssignment(parent) || Node.isSpreadAssignment(parent) || Node.isPropertyAssignment(parent) && parent.getInitializer() === id || Node.isAsExpression(parent) || Node.isSatisfiesExpression(parent);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* `name` in an actions map bound to a module `const name = assign(...)` whose
|
|
894
|
+
* only references are entries of implementation maps.
|
|
895
|
+
*/
|
|
896
|
+
function implConstAssign(id, xs) {
|
|
897
|
+
const parent = id.getParent();
|
|
898
|
+
const symbol = Node.isShorthandPropertyAssignment(parent) ? parent.getValueSymbol() : id.getSymbol();
|
|
899
|
+
const decls = symbol?.getDeclarations() ?? [];
|
|
900
|
+
if (decls.length !== 1) return undefined;
|
|
901
|
+
const nameId = Node.isVariableDeclaration(decls[0]) ? decls[0].getNameNode() : undefined;
|
|
902
|
+
if (!nameId) return undefined;
|
|
903
|
+
const found = resolveConstAction(nameId, xs);
|
|
904
|
+
if (!found || found.callee !== 'assign') return undefined;
|
|
905
|
+
const [arg, ...rest] = found.call.getArguments();
|
|
906
|
+
if (!arg || rest.length) return undefined;
|
|
907
|
+
const refsOk = id.getSourceFile().getDescendantsOfKind(SyntaxKind.Identifier).filter(r => r !== nameId && r.getText() === nameId.getText()).every(r => {
|
|
908
|
+
const p = r.getParent();
|
|
909
|
+
const s = Node.isShorthandPropertyAssignment(p) ? p.getValueSymbol() : r.getSymbol();
|
|
910
|
+
if (s !== symbol) return true;
|
|
911
|
+
let value = r;
|
|
912
|
+
while (Node.isAsExpression(value.getParent()) || Node.isSatisfiesExpression(value.getParent()) || Node.isParenthesizedExpression(value.getParent())) value = value.getParent();
|
|
913
|
+
const outer = value.getParent();
|
|
914
|
+
const owner = Node.isShorthandPropertyAssignment(p) ? p : Node.isPropertyAssignment(outer) && outer.getInitializer() === value ? outer : undefined;
|
|
915
|
+
const map = owner?.getParent()?.getParent();
|
|
916
|
+
return !!owner && Node.isPropertyAssignment(map) && getKeyText$2(map) === 'actions';
|
|
917
|
+
});
|
|
918
|
+
return refsOk ? {
|
|
919
|
+
assignArg: arg,
|
|
920
|
+
constDecl: found.decl
|
|
921
|
+
} : undefined;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** The `'xstate'` export an identifier is a value import of, if any. */
|
|
925
|
+
function xstateImportOf(id) {
|
|
926
|
+
if (!Node.isIdentifier(id)) return undefined;
|
|
927
|
+
for (const decl of id.getSymbol()?.getDeclarations() ?? []) {
|
|
928
|
+
if (!Node.isImportSpecifier(decl) || decl.isTypeOnly()) continue;
|
|
929
|
+
const imp = decl.getImportDeclaration();
|
|
930
|
+
if (imp.getModuleSpecifierValue() === 'xstate' && !imp.isTypeOnly()) {
|
|
931
|
+
return decl.getName();
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return undefined;
|
|
935
|
+
}
|
|
936
|
+
function isXstateNamespace(id) {
|
|
937
|
+
if (!Node.isIdentifier(id)) return false;
|
|
938
|
+
return (id.getSymbol()?.getDeclarations() ?? []).some(decl => Node.isNamespaceImport(decl) && decl.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)?.getModuleSpecifierValue() === 'xstate');
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** `xstate`'s `name` called as `name(...)` or `ns.name(...)`. */
|
|
942
|
+
function callsXstate(call, names) {
|
|
943
|
+
if (!Node.isCallExpression(call)) return false;
|
|
944
|
+
const callee = call.getExpression();
|
|
945
|
+
if (Node.isIdentifier(callee)) {
|
|
946
|
+
return names.includes(xstateImportOf(callee) ?? '');
|
|
947
|
+
}
|
|
948
|
+
return Node.isPropertyAccessExpression(callee) && names.includes(callee.getName()) && isXstateNamespace(callee.getExpression());
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Whether a config object is the first argument of xstate's `createMachine` /
|
|
953
|
+
* `createStateConfig`, directly or chained on xstate's `setup(...)`.
|
|
954
|
+
*/
|
|
955
|
+
function isXstateMachineCall(call) {
|
|
956
|
+
if (!Node.isCallExpression(call)) return false;
|
|
957
|
+
if (callsXstate(call, ['createMachine', 'createStateConfig'])) return true;
|
|
958
|
+
const callee = call.getExpression();
|
|
959
|
+
if (!Node.isPropertyAccessExpression(callee)) return false;
|
|
960
|
+
if (!['createMachine', 'createStateConfig'].includes(callee.getName())) return false;
|
|
961
|
+
let receiver = unwrap(callee.getExpression());
|
|
962
|
+
if (Node.isIdentifier(receiver)) {
|
|
963
|
+
const decl = receiver.getSymbol()?.getDeclarations().find(Node.isVariableDeclaration);
|
|
964
|
+
const init = decl?.getInitializer();
|
|
965
|
+
if (!init) return false;
|
|
966
|
+
receiver = unwrap(init);
|
|
967
|
+
}
|
|
968
|
+
return callsXstate(receiver, ['setup']);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Replaced by the local name of `matchesState` once the import is ensured. */
|
|
972
|
+
const MATCHES_STATE = '__xstate_codemod_matchesState__';
|
|
973
|
+
|
|
974
|
+
/** What v5's `enqueueActions` callback may destructure. */
|
|
975
|
+
const ENQUEUE_ARGS = new Set(['enqueue', 'check', 'context', 'event', 'self', 'system']);
|
|
976
|
+
const ENQUEUE_METHODS = new Set(['assign', 'raise', 'sendTo', 'sendParent', 'emit', 'log', 'cancel', 'stopChild', 'spawnChild']);
|
|
977
|
+
|
|
978
|
+
/** Indentation of the line a node starts on. */
|
|
979
|
+
function indentOf(node) {
|
|
980
|
+
const text = node.getSourceFile().getFullText();
|
|
981
|
+
const lineStart = text.lastIndexOf('\n', node.getStart() - 1) + 1;
|
|
982
|
+
return /^[ \t]*/.exec(text.slice(lineStart))[0];
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The symbol a (possibly shorthand-property) identifier refers to. */
|
|
986
|
+
function valueSymbol(id) {
|
|
987
|
+
const parent = id.getParent();
|
|
988
|
+
if (Node.isShorthandPropertyAssignment(parent)) {
|
|
989
|
+
return id.getProject().getTypeChecker().compilerObject.getShorthandAssignmentValueSymbol(parent.compilerNode);
|
|
990
|
+
}
|
|
991
|
+
return id.getSymbol()?.compilerSymbol;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** `node`'s text with non-overlapping descendant ranges replaced. */
|
|
995
|
+
function spliceText(node, replacements) {
|
|
996
|
+
const start = node.getStart();
|
|
997
|
+
let text = node.getText();
|
|
998
|
+
const inside = replacements.filter(r => r.node.getStart() >= start && r.node.getEnd() <= node.getEnd()).filter((r, _, all) => !all.some(o => o !== r && o.node.getStart() <= r.node.getStart() && o.node.getEnd() >= r.node.getEnd() && o.node !== r.node)).sort((a, b) => b.node.getStart() - a.node.getStart());
|
|
999
|
+
for (const r of inside) {
|
|
1000
|
+
text = text.slice(0, r.node.getStart() - start) + r.text + text.slice(r.node.getEnd() - start);
|
|
1001
|
+
}
|
|
1002
|
+
return text;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/** Names v5 passed to actions and guards that the v6 `args` object also has. */
|
|
1006
|
+
const HOISTABLE = new Set(['context', 'event', 'self', 'system']);
|
|
1007
|
+
const RESERVED = new Set(['args', 'enq', 'context', 'patch']);
|
|
1008
|
+
const TRANSITION_KEYS = new Set(['target', 'actions', 'guard', 'reenter', 'description', 'meta']);
|
|
1009
|
+
/** `({ a, b }) => expr` (or `() => expr`) whose body can be substituted in place. */
|
|
1010
|
+
function inlineable(fn) {
|
|
1011
|
+
if (!Node.isArrowFunction(fn) || fn.isAsync()) return undefined;
|
|
1012
|
+
const body = fn.getBody();
|
|
1013
|
+
if (Node.isBlock(body)) return undefined;
|
|
1014
|
+
const params = fn.getParameters();
|
|
1015
|
+
if (params.length === 0) return {
|
|
1016
|
+
names: [],
|
|
1017
|
+
body
|
|
1018
|
+
};
|
|
1019
|
+
if (params.length !== 1) return undefined;
|
|
1020
|
+
const param = params[0];
|
|
1021
|
+
if (param.getInitializer() || param.isRestParameter()) return undefined;
|
|
1022
|
+
const pattern = param.getNameNode();
|
|
1023
|
+
if (!Node.isObjectBindingPattern(pattern)) return undefined;
|
|
1024
|
+
const names = [];
|
|
1025
|
+
for (const element of pattern.getElements()) {
|
|
1026
|
+
const name = element.getNameNode();
|
|
1027
|
+
if (element.getPropertyNameNode() || element.getInitializer() || element.getDotDotDotToken() || !Node.isIdentifier(name) || !HOISTABLE.has(name.getText())) return undefined;
|
|
1028
|
+
names.push(name.getText());
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
names,
|
|
1032
|
+
body
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function isAtomic(node) {
|
|
1036
|
+
return Node.isIdentifier(node) || Node.isPropertyAccessExpression(node) || Node.isElementAccessExpression(node) || Node.isCallExpression(node) || Node.isParenthesizedExpression(node) || Node.isStringLiteral(node) || Node.isNumericLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node) || Node.isTrueLiteral(node) || Node.isFalseLiteral(node) || Node.isPrefixUnaryExpression(node);
|
|
1037
|
+
}
|
|
1038
|
+
/** A context patch: object-literal properties, or an expression evaluating to one. */
|
|
1039
|
+
|
|
1040
|
+
class Branch {
|
|
1041
|
+
constructor() {
|
|
1042
|
+
this.entries = [];
|
|
1043
|
+
this.assigned = false;
|
|
1044
|
+
this.enqueued = false;
|
|
1045
|
+
/** Patches written from nested control flow: always accumulate in `patch`. */
|
|
1046
|
+
this.forcePatch = false;
|
|
1047
|
+
/** Literal child ids spawned so far → index of the spawning entry. */
|
|
1048
|
+
this.spawns = new Map();
|
|
1049
|
+
/** An assigner received `spawn`, so child ids are unknown statically. */
|
|
1050
|
+
this.spawnAssign = false;
|
|
1051
|
+
/** A `stopChild('id')` was lowered to `args.children[id]`. */
|
|
1052
|
+
this.stringStop = false;
|
|
1053
|
+
/** Arguments text for guards evaluated against an earlier context. */
|
|
1054
|
+
this.argsOverride = void 0;
|
|
1055
|
+
}
|
|
1056
|
+
argsObj() {
|
|
1057
|
+
if (this.argsOverride) return this.argsOverride;
|
|
1058
|
+
return this.assigned ? '{ ...args, context }' : 'args';
|
|
1059
|
+
}
|
|
1060
|
+
push(line, enqueues = true) {
|
|
1061
|
+
this.entries.push(line);
|
|
1062
|
+
if (enqueues) this.enqueued = true;
|
|
1063
|
+
}
|
|
1064
|
+
pushPatch(patch) {
|
|
1065
|
+
this.entries.push(patch);
|
|
1066
|
+
this.assigned = true;
|
|
1067
|
+
}
|
|
1068
|
+
/**
|
|
1069
|
+
* Statement lines plus the `context` return field. Only the patch is
|
|
1070
|
+
* returned, never the whole threaded context: v6 evaluates the transitions
|
|
1071
|
+
* of parallel regions against the same context and merges their patches,
|
|
1072
|
+
* so returning every key would undo a sibling region's update.
|
|
1073
|
+
*/
|
|
1074
|
+
finish(fb) {
|
|
1075
|
+
const patches = this.entries.filter(e => typeof e !== 'string');
|
|
1076
|
+
if (!patches.length && !this.forcePatch) {
|
|
1077
|
+
return {
|
|
1078
|
+
lines: this.entries,
|
|
1079
|
+
threaded: false
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
if (!this.forcePatch) {
|
|
1083
|
+
// Nothing after the first assign reads `context`: the patches can be
|
|
1084
|
+
// merged straight into the returned object (effects are queued anyway).
|
|
1085
|
+
const first = this.entries.findIndex(e => typeof e !== 'string');
|
|
1086
|
+
const reads = this.entries.slice(first + 1).some(e => /\bcontext\b/.test(typeof e === 'string' ? e : 'props' in e ? e.props : e.expr));
|
|
1087
|
+
if (!reads) {
|
|
1088
|
+
const merged = patches.length === 1 ? 'props' in patches[0] ? `{ ${patches[0].props} }` : patches[0].expr : `{ ${patches.map(p => 'props' in p ? p.props : `...${p.expr}`).join(', ')} }`;
|
|
1089
|
+
return {
|
|
1090
|
+
lines: this.entries.filter(e => typeof e === 'string'),
|
|
1091
|
+
context: `context: ${merged}`,
|
|
1092
|
+
threaded: false
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
fb.threaded = true;
|
|
1097
|
+
return {
|
|
1098
|
+
lines: ['let context = args.context;', 'let patch = {};', ...threadedLines(this.entries)],
|
|
1099
|
+
context: 'context: patch',
|
|
1100
|
+
threaded: true
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function threadedLines(entries) {
|
|
1105
|
+
return entries.flatMap(e => typeof e === 'string' ? [e] : ['props' in e ? `patch = { ...patch, ${e.props} };` : `patch = { ...patch, ...${e.expr} };`, 'context = { ...args.context, ...patch };']);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Builds one generated `(args, enq) => { ... }` function. */
|
|
1109
|
+
class FunctionBuilder {
|
|
1110
|
+
/** Source text of `node`, with active renames applied. */
|
|
1111
|
+
t(node) {
|
|
1112
|
+
return this.renames.length ? spliceText(node, this.renames) : node.getText();
|
|
1113
|
+
}
|
|
1114
|
+
paren(node) {
|
|
1115
|
+
return isAtomic(node) ? this.t(node) : `(${this.t(node)})`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/** `params` of the named action being inlined, passed to its dynamic values. */
|
|
1119
|
+
|
|
1120
|
+
constructor(ctx, node) {
|
|
1121
|
+
this.hoist = new Set();
|
|
1122
|
+
/** Named definitions inlined into this function. */
|
|
1123
|
+
this.inlinedDefs = new Set();
|
|
1124
|
+
/** Module-level action consts called here, with the referencing identifiers. */
|
|
1125
|
+
this.constRefs = new Map();
|
|
1126
|
+
/** True when some branch keeps a running `context` and `patch`. */
|
|
1127
|
+
this.threaded = false;
|
|
1128
|
+
this.inline = void 0;
|
|
1129
|
+
/** Identifier renames applied to source text this builder copies. */
|
|
1130
|
+
this.renames = [];
|
|
1131
|
+
this.paramsText = void 0;
|
|
1132
|
+
this.ctx = void 0;
|
|
1133
|
+
this.ctx = ctx;
|
|
1134
|
+
const reserved = outerReferences(node, RESERVED);
|
|
1135
|
+
if (reserved.size) {
|
|
1136
|
+
skip(`references outer '${[...reserved].join("', '")}', which the generated function would shadow`);
|
|
1137
|
+
}
|
|
1138
|
+
this.inline = outerReferences(node, new Set(['event', 'self', 'system'])).size === 0;
|
|
1139
|
+
}
|
|
1140
|
+
inlined(fn) {
|
|
1141
|
+
if (!this.inline) return undefined;
|
|
1142
|
+
const result = inlineable(fn);
|
|
1143
|
+
if (result) result.names.forEach(name => this.hoist.add(name));
|
|
1144
|
+
return result;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** A value v5 accepted either directly or as a function of the action args. */
|
|
1148
|
+
dynamic(node, br, argsText = br.argsObj()) {
|
|
1149
|
+
const value = unwrap(node);
|
|
1150
|
+
if (isFunctionLike(value)) {
|
|
1151
|
+
const inline = this.inlined(value);
|
|
1152
|
+
const p = this.paramsText ? `, ${this.paramsText}` : '';
|
|
1153
|
+
if (inline) return this.t(inline.body);
|
|
1154
|
+
return `(${this.t(value)})(${argsText}${p})`;
|
|
1155
|
+
}
|
|
1156
|
+
if (Node.isIdentifier(value)) {
|
|
1157
|
+
if (value.getText() === 'undefined') return 'undefined';
|
|
1158
|
+
const kind = resolveIdentifier(value);
|
|
1159
|
+
const p = this.paramsText ? `, ${this.paramsText}` : '';
|
|
1160
|
+
if (kind === 'fn') return `${value.getText()}(${argsText}${p})`;
|
|
1161
|
+
if (kind === 'value') return this.t(node);
|
|
1162
|
+
return skip(`cannot tell whether '${value.getText()}' is a function`);
|
|
1163
|
+
}
|
|
1164
|
+
if (Node.isCallExpression(value) || Node.isNewExpression(value)) {
|
|
1165
|
+
return skip(`'${value.getText()}' may evaluate to a function`);
|
|
1166
|
+
}
|
|
1167
|
+
return this.t(node);
|
|
1168
|
+
}
|
|
1169
|
+
options(node, br, keys) {
|
|
1170
|
+
if (!node) return '';
|
|
1171
|
+
const value = unwrap(node);
|
|
1172
|
+
if (!Node.isObjectLiteralExpression(value)) {
|
|
1173
|
+
return skip('options are not an object literal');
|
|
1174
|
+
}
|
|
1175
|
+
const parts = value.getProperties().map(property => {
|
|
1176
|
+
const key = getKeyText$2(property);
|
|
1177
|
+
if (!key || !keys.includes(key) || !Node.isPropertyAssignment(property)) {
|
|
1178
|
+
return skip(`unsupported option '${property.getText()}'`);
|
|
1179
|
+
}
|
|
1180
|
+
return `${key}: ${this.dynamic(property.getInitializer(), br)}`;
|
|
1181
|
+
});
|
|
1182
|
+
return parts.length ? `, { ${parts.join(', ')} }` : '';
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// ---- guards ---------------------------------------------------------------
|
|
1186
|
+
|
|
1187
|
+
guard(node, br) {
|
|
1188
|
+
const value = unwrap(node);
|
|
1189
|
+
const ref = Node.isIdentifier(value) ? undefined : nameRef(value);
|
|
1190
|
+
if (ref) return this.namedGuard(ref, undefined, br);
|
|
1191
|
+
if (Node.isObjectLiteralExpression(value)) {
|
|
1192
|
+
const {
|
|
1193
|
+
ref,
|
|
1194
|
+
params
|
|
1195
|
+
} = typeAndParams(value, 'guard');
|
|
1196
|
+
return this.namedGuard(ref, params, br);
|
|
1197
|
+
}
|
|
1198
|
+
if (isFunctionLike(value)) {
|
|
1199
|
+
const inline = this.inlined(value);
|
|
1200
|
+
if (inline) {
|
|
1201
|
+
return {
|
|
1202
|
+
text: this.t(inline.body),
|
|
1203
|
+
atomic: isAtomic(inline.body)
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
text: `(${this.t(value)})(${br.argsObj()})`,
|
|
1208
|
+
atomic: true
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
if (Node.isIdentifier(value)) {
|
|
1212
|
+
if (resolveIdentifier(value) === 'fn') {
|
|
1213
|
+
return {
|
|
1214
|
+
text: `${value.getText()}(${br.argsObj()})`,
|
|
1215
|
+
atomic: true
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const named = resolveStringConstant(value);
|
|
1219
|
+
if (named !== undefined) {
|
|
1220
|
+
return this.namedGuard({
|
|
1221
|
+
name: named,
|
|
1222
|
+
access: access(named)
|
|
1223
|
+
}, undefined, br);
|
|
1224
|
+
}
|
|
1225
|
+
return skip(`unresolved guard '${value.getText()}'`);
|
|
1226
|
+
}
|
|
1227
|
+
const callee = xstateCallee(value, this.ctx.xs);
|
|
1228
|
+
if (callee && Node.isCallExpression(value)) {
|
|
1229
|
+
const [arg, ...rest] = value.getArguments();
|
|
1230
|
+
if (!arg || rest.length) return skip(`unsupported ${callee}(...) call`);
|
|
1231
|
+
switch (callee) {
|
|
1232
|
+
case 'and':
|
|
1233
|
+
case 'or':
|
|
1234
|
+
{
|
|
1235
|
+
const list = unwrap(arg);
|
|
1236
|
+
if (!Node.isArrayLiteralExpression(list)) {
|
|
1237
|
+
return skip(`${callee}(...) argument is not an array literal`);
|
|
1238
|
+
}
|
|
1239
|
+
const guards = list.getElements().map(element => this.guard(element, br));
|
|
1240
|
+
if (!guards.length) {
|
|
1241
|
+
return {
|
|
1242
|
+
text: callee === 'and' ? 'true' : 'false',
|
|
1243
|
+
atomic: true
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
if (guards.length === 1) return guards[0];
|
|
1247
|
+
return {
|
|
1248
|
+
text: guards.map(g => g.atomic ? g.text : `(${g.text})`).join(callee === 'and' ? ' && ' : ' || '),
|
|
1249
|
+
atomic: false
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
case 'not':
|
|
1253
|
+
{
|
|
1254
|
+
const inner = this.guard(arg, br);
|
|
1255
|
+
return {
|
|
1256
|
+
text: `!${inner.atomic ? inner.text : `(${inner.text})`}`,
|
|
1257
|
+
atomic: true
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
case 'stateIn':
|
|
1261
|
+
{
|
|
1262
|
+
const state = unwrap(arg);
|
|
1263
|
+
if (!(Node.isStringLiteral(state) && !state.getLiteralText().startsWith('#') || Node.isObjectLiteralExpression(state))) {
|
|
1264
|
+
return skip('stateIn(...) argument is not a state value literal');
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
text: `${MATCHES_STATE}(${state.getText()}, args.value)`,
|
|
1268
|
+
atomic: true
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
return skip(`unsupported guard '${value.getText()}'`);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Throws unless a named definition can be inlined at a call site. */
|
|
1277
|
+
inlineCheck(def, name) {
|
|
1278
|
+
const expr = def.expr;
|
|
1279
|
+
const local = nonModuleReferences(expr);
|
|
1280
|
+
if (local.length) {
|
|
1281
|
+
skip(`'${name}' references '${local.join("', '")}', which are not module-scope`);
|
|
1282
|
+
}
|
|
1283
|
+
const shadowed = outerReferences(expr, new Set([...RESERVED, ...HOISTABLE, 'params']));
|
|
1284
|
+
if (shadowed.size) {
|
|
1285
|
+
skip(`'${name}' references outer '${[...shadowed].join("', '")}', which the generated function would shadow`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
namedGuard(ref, params, br) {
|
|
1289
|
+
const {
|
|
1290
|
+
name
|
|
1291
|
+
} = ref;
|
|
1292
|
+
const def = this.ctx.impls.guards.get(name);
|
|
1293
|
+
if (def?.kind === 'inline') {
|
|
1294
|
+
if (params) return skip(`guard '${name}' is a combinator given params`);
|
|
1295
|
+
this.inlineCheck(def, name);
|
|
1296
|
+
this.inlinedDefs.add(def);
|
|
1297
|
+
return this.guard(def.expr, br);
|
|
1298
|
+
}
|
|
1299
|
+
if (!def || def.kind !== 'fn') {
|
|
1300
|
+
return skip(`guard '${name}' is not a function defined in this file`);
|
|
1301
|
+
}
|
|
1302
|
+
const p = params ? `, ${this.dynamic(params, br)}` : '';
|
|
1303
|
+
return {
|
|
1304
|
+
text: `args.guards${ref.access}(${br.argsObj()}${p})`,
|
|
1305
|
+
atomic: true
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// ---- actions --------------------------------------------------------------
|
|
1310
|
+
|
|
1311
|
+
/** True when the item writes context (an `assign`, inline or named). */
|
|
1312
|
+
isAssign(node) {
|
|
1313
|
+
const value = unwrap(node);
|
|
1314
|
+
if (xstateCallee(value, this.ctx.xs) === 'assign') return true;
|
|
1315
|
+
if (Node.isIdentifier(value)) {
|
|
1316
|
+
const constAction = resolveConstAction(value, this.ctx.xs);
|
|
1317
|
+
if (constAction?.callee === 'assign') {
|
|
1318
|
+
return !this.ctx.disabledConsts.has(constAction.decl);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const name = Node.isObjectLiteralExpression(value) ? nameRef(getInit(value, 'type'))?.name : Node.isIdentifier(value) ? undefined : nameRef(value)?.name;
|
|
1322
|
+
return !!name && this.ctx.impls.actions.get(name)?.kind === 'assign';
|
|
1323
|
+
}
|
|
1324
|
+
actions(node, br) {
|
|
1325
|
+
if (!node) return;
|
|
1326
|
+
const value = unwrap(node);
|
|
1327
|
+
const items = Node.isArrayLiteralExpression(value) ? value.getElements() : [value];
|
|
1328
|
+
let lastAssign = -1;
|
|
1329
|
+
items.forEach((item, i) => {
|
|
1330
|
+
if (this.isAssign(item)) lastAssign = i;
|
|
1331
|
+
});
|
|
1332
|
+
items.forEach((item, i) => {
|
|
1333
|
+
for (const comment of leadingComments(item)) br.push(comment, false);
|
|
1334
|
+
this.action(item, br, i > lastAssign);
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
action(node, br, contextIsFinal) {
|
|
1338
|
+
const value = unwrap(node);
|
|
1339
|
+
const ref = Node.isIdentifier(value) ? undefined : nameRef(value);
|
|
1340
|
+
if (ref) return this.namedAction(ref, undefined, br);
|
|
1341
|
+
if (Node.isObjectLiteralExpression(value)) {
|
|
1342
|
+
const {
|
|
1343
|
+
ref,
|
|
1344
|
+
params
|
|
1345
|
+
} = typeAndParams(value, 'action');
|
|
1346
|
+
return this.namedAction(ref, params, br);
|
|
1347
|
+
}
|
|
1348
|
+
if (Node.isIdentifier(value)) {
|
|
1349
|
+
const named = resolveStringConstant(value);
|
|
1350
|
+
if (named !== undefined) {
|
|
1351
|
+
return this.namedAction({
|
|
1352
|
+
name: named,
|
|
1353
|
+
access: access(named)
|
|
1354
|
+
}, undefined, br);
|
|
1355
|
+
}
|
|
1356
|
+
const constAction = resolveConstAction(value, this.ctx.xs);
|
|
1357
|
+
if (constAction && !this.ctx.disabledConsts.has(constAction.decl)) {
|
|
1358
|
+
const refs = this.constRefs.get(constAction.decl) ?? [];
|
|
1359
|
+
refs.push(value);
|
|
1360
|
+
this.constRefs.set(constAction.decl, refs);
|
|
1361
|
+
if (constAction.callee === 'assign') {
|
|
1362
|
+
const argsText = mentionsSpawn(constAction.call) ? spawnArgs(br) : br.argsObj();
|
|
1363
|
+
return br.pushPatch({
|
|
1364
|
+
expr: `${value.getText()}(${argsText})`
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
return br.push(`${value.getText()}(${br.argsObj()}, enq);`);
|
|
1368
|
+
}
|
|
1369
|
+
if (resolveIdentifier(value) !== 'fn') {
|
|
1370
|
+
return skip(`unresolved action '${value.getText()}'`);
|
|
1371
|
+
}
|
|
1372
|
+
return br.push(`enq(${value.getText()}, ${br.argsObj()});`);
|
|
1373
|
+
}
|
|
1374
|
+
if (isFunctionLike(value)) {
|
|
1375
|
+
const candidate = inlineable(value);
|
|
1376
|
+
if (candidate && (contextIsFinal || !candidate.names.includes('context'))) {
|
|
1377
|
+
const inline = this.inlined(value);
|
|
1378
|
+
if (inline) return br.push(`enq(() => ${this.t(inline.body)});`);
|
|
1379
|
+
}
|
|
1380
|
+
return br.push(`enq(${this.t(value)}, ${br.argsObj()});`);
|
|
1381
|
+
}
|
|
1382
|
+
const callee = xstateCallee(value, this.ctx.xs);
|
|
1383
|
+
if (!callee || !Node.isCallExpression(value)) {
|
|
1384
|
+
return skip(`unsupported action '${value.getText()}'`);
|
|
1385
|
+
}
|
|
1386
|
+
return this.creator(callee, value.getArguments(), br);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** Rule C for `callee(...args)`, an action creator call. */
|
|
1390
|
+
creator(callee, args, br) {
|
|
1391
|
+
const arity = (min, max) => {
|
|
1392
|
+
if (args.length < min || args.length > max) {
|
|
1393
|
+
skip(`unsupported ${callee}(...) arity`);
|
|
1394
|
+
}
|
|
1395
|
+
};
|
|
1396
|
+
switch (callee) {
|
|
1397
|
+
case 'assign':
|
|
1398
|
+
arity(1, 1);
|
|
1399
|
+
return this.assign(args[0], br);
|
|
1400
|
+
case 'raise':
|
|
1401
|
+
arity(1, 2);
|
|
1402
|
+
return br.push(`enq.raise(${this.dynamic(args[0], br)}${this.options(args[1], br, ['delay', 'id'])});`);
|
|
1403
|
+
case 'sendTo':
|
|
1404
|
+
arity(2, 3);
|
|
1405
|
+
return br.push(`enq.sendTo(${this.target(args[0], br)}, ${this.dynamic(args[1], br)}${this.options(args[2], br, ['delay', 'id'])});`);
|
|
1406
|
+
case 'sendParent':
|
|
1407
|
+
arity(1, 2);
|
|
1408
|
+
return br.push(`enq.sendTo(args.parent, ${this.dynamic(args[0], br)}${this.options(args[1], br, ['delay', 'id'])});`);
|
|
1409
|
+
case 'forwardTo':
|
|
1410
|
+
arity(1, 2);
|
|
1411
|
+
return br.push(`enq.sendTo(${this.target(args[0], br)}, args.event${this.options(args[1], br, ['delay', 'id'])});`);
|
|
1412
|
+
case 'emit':
|
|
1413
|
+
arity(1, 1);
|
|
1414
|
+
return br.push(`enq.emit(${this.dynamic(args[0], br)});`);
|
|
1415
|
+
case 'log':
|
|
1416
|
+
{
|
|
1417
|
+
arity(0, 2);
|
|
1418
|
+
const logged = args[0] ? this.dynamic(args[0], br) : `{ context: ${br.assigned ? 'context' : 'args.context'}, event: args.event }`;
|
|
1419
|
+
const label = args[1];
|
|
1420
|
+
if (label && !Node.isStringLiteral(unwrap(label))) {
|
|
1421
|
+
return skip('log label is not a string literal');
|
|
1422
|
+
}
|
|
1423
|
+
return br.push(label ? `enq.log(${label.getText()}, ${logged});` : `enq.log(${logged});`);
|
|
1424
|
+
}
|
|
1425
|
+
case 'cancel':
|
|
1426
|
+
arity(1, 1);
|
|
1427
|
+
return br.push(`enq.cancel(${this.dynamic(args[0], br)});`);
|
|
1428
|
+
case 'stop':
|
|
1429
|
+
case 'stopChild':
|
|
1430
|
+
{
|
|
1431
|
+
arity(1, 1);
|
|
1432
|
+
const target = unwrap(args[0]);
|
|
1433
|
+
if (!Node.isStringLiteral(target)) {
|
|
1434
|
+
return br.push(`enq.stop(${this.dynamic(args[0], br)});`);
|
|
1435
|
+
}
|
|
1436
|
+
if (br.spawnAssign) {
|
|
1437
|
+
return skip('stopChild by id after an assign that spawns: the child may not exist in args.children yet');
|
|
1438
|
+
}
|
|
1439
|
+
const at = br.spawns.get(target.getLiteralText());
|
|
1440
|
+
if (at !== undefined) {
|
|
1441
|
+
// v5 adds spawned children immediately; v6 queues the spawn, so
|
|
1442
|
+
// stop the ref it returns instead of reading args.children.
|
|
1443
|
+
const line = br.entries[at];
|
|
1444
|
+
const name = `child${at}`;
|
|
1445
|
+
if (line.startsWith('enq.spawn(')) {
|
|
1446
|
+
br.entries[at] = `const ${name} = ${line}`;
|
|
1447
|
+
}
|
|
1448
|
+
return br.push(`enq.stop(${name});`);
|
|
1449
|
+
}
|
|
1450
|
+
br.stringStop = true;
|
|
1451
|
+
return br.push(`enq.stop(args.children[${target.getText()}]);`);
|
|
1452
|
+
}
|
|
1453
|
+
case 'spawnChild':
|
|
1454
|
+
{
|
|
1455
|
+
arity(1, 2);
|
|
1456
|
+
const src = unwrap(args[0]);
|
|
1457
|
+
if (!Node.isStringLiteral(src) && !Node.isIdentifier(src)) {
|
|
1458
|
+
return skip('spawnChild source is not a name or identifier');
|
|
1459
|
+
}
|
|
1460
|
+
br.push(`enq.spawn(${args[0].getText()}${this.options(args[1], br, ['id', 'input'])});`);
|
|
1461
|
+
const options = args[1] && unwrap(args[1]);
|
|
1462
|
+
const id = options && Node.isObjectLiteralExpression(options) && getInit(options, 'id');
|
|
1463
|
+
const idValue = id && unwrap(id);
|
|
1464
|
+
if (idValue && Node.isStringLiteral(idValue)) {
|
|
1465
|
+
br.spawns.set(idValue.getLiteralText(), br.entries.length - 1);
|
|
1466
|
+
}
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
case 'enqueueActions':
|
|
1470
|
+
arity(1, 1);
|
|
1471
|
+
return this.enqueueActions(args[0], br);
|
|
1472
|
+
}
|
|
1473
|
+
return skip(`'${callee}(...)' cannot be lowered`);
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* `enqueueActions(({ enqueue, check, ... }, params) => BODY)`: BODY is kept
|
|
1478
|
+
* with every `enqueue...(...)` call replaced by its rule-C statements and
|
|
1479
|
+
* every `check(g)` by its rule-B expression. v5's `context` and `check` see
|
|
1480
|
+
* the context from before the callback, even after `enqueue.assign`.
|
|
1481
|
+
*/
|
|
1482
|
+
enqueueActions(node, br) {
|
|
1483
|
+
const fn = unwrap(node);
|
|
1484
|
+
if (!Node.isArrowFunction(fn) && !Node.isFunctionExpression(fn)) {
|
|
1485
|
+
return skip('enqueueActions argument is not an inline function');
|
|
1486
|
+
}
|
|
1487
|
+
if (fn.isAsync()) return skip('enqueueActions callback is async');
|
|
1488
|
+
const [first, second, ...more] = fn.getParameters();
|
|
1489
|
+
if (more.length) return skip('enqueueActions callback has extra params');
|
|
1490
|
+
const bound = new Map();
|
|
1491
|
+
if (first) {
|
|
1492
|
+
const pattern = first.getNameNode();
|
|
1493
|
+
if (!Node.isObjectBindingPattern(pattern) || first.getInitializer()) {
|
|
1494
|
+
return skip('enqueueActions callback does not destructure its argument');
|
|
1495
|
+
}
|
|
1496
|
+
for (const element of pattern.getElements()) {
|
|
1497
|
+
const name = element.getNameNode();
|
|
1498
|
+
const key = (element.getPropertyNameNode() ?? name).getText();
|
|
1499
|
+
if (element.getPropertyNameNode() || element.getInitializer() || element.getDotDotDotToken() || !Node.isIdentifier(name) || !ENQUEUE_ARGS.has(key)) {
|
|
1500
|
+
return skip(`unsupported enqueueActions binding '${element.getText()}'`);
|
|
1501
|
+
}
|
|
1502
|
+
bound.set(key, name);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
let paramsName;
|
|
1506
|
+
if (second) {
|
|
1507
|
+
const name = second.getNameNode();
|
|
1508
|
+
if (!Node.isIdentifier(name) || second.getInitializer()) {
|
|
1509
|
+
return skip('enqueueActions params is not a plain identifier');
|
|
1510
|
+
}
|
|
1511
|
+
paramsName = name.getText();
|
|
1512
|
+
}
|
|
1513
|
+
const refsTo = key => {
|
|
1514
|
+
const id = bound.get(key);
|
|
1515
|
+
if (!id) return [];
|
|
1516
|
+
const symbol = valueSymbol(id);
|
|
1517
|
+
return fn.getDescendantsOfKind(SyntaxKind.Identifier).filter(r => r !== id && valueSymbol(r) === symbol);
|
|
1518
|
+
};
|
|
1519
|
+
// Calls to replace: enqueue(...), enqueue.x(...), check(...).
|
|
1520
|
+
const calls = [];
|
|
1521
|
+
for (const ref of refsTo('enqueue')) {
|
|
1522
|
+
const parent = ref.getParentOrThrow();
|
|
1523
|
+
if (Node.isCallExpression(parent) && parent.getExpression() === ref) {
|
|
1524
|
+
calls.push({
|
|
1525
|
+
call: parent,
|
|
1526
|
+
kind: 'enqueue',
|
|
1527
|
+
args: parent.getArguments()
|
|
1528
|
+
});
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
const call = parent.getParent();
|
|
1532
|
+
if (Node.isPropertyAccessExpression(parent) && Node.isCallExpression(call) && call.getExpression() === parent && ENQUEUE_METHODS.has(parent.getName())) {
|
|
1533
|
+
calls.push({
|
|
1534
|
+
call,
|
|
1535
|
+
kind: parent.getName(),
|
|
1536
|
+
args: call.getArguments()
|
|
1537
|
+
});
|
|
1538
|
+
continue;
|
|
1539
|
+
}
|
|
1540
|
+
return skip('enqueueActions uses `enqueue` other than by calling it');
|
|
1541
|
+
}
|
|
1542
|
+
for (const ref of refsTo('check')) {
|
|
1543
|
+
const parent = ref.getParentOrThrow();
|
|
1544
|
+
if (!Node.isCallExpression(parent) || parent.getExpression() !== ref || parent.getArguments().length !== 1) {
|
|
1545
|
+
return skip('enqueueActions uses `check` other than as check(guard)');
|
|
1546
|
+
}
|
|
1547
|
+
calls.push({
|
|
1548
|
+
call: parent,
|
|
1549
|
+
kind: 'check',
|
|
1550
|
+
args: parent.getArguments()
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
const within = (inner, outer) => inner !== outer && inner.getStart() >= outer.getStart() && inner.getEnd() <= outer.getEnd();
|
|
1554
|
+
if (calls.some(a => calls.some(b => within(a.call, b.call)))) {
|
|
1555
|
+
return skip('enqueueActions nests enqueue/check calls');
|
|
1556
|
+
}
|
|
1557
|
+
const body = fn.getBody();
|
|
1558
|
+
if (Node.isBlock(body) && body.getDescendantsOfKind(SyntaxKind.ReturnStatement).some(r => r.getFirstAncestor(a => Node.isArrowFunction(a) || Node.isFunctionExpression(a) || Node.isFunctionDeclaration(a) || Node.isMethodDeclaration(a)) === fn)) {
|
|
1559
|
+
return skip('enqueueActions callback returns early');
|
|
1560
|
+
}
|
|
1561
|
+
const hoistable = ['event', 'self', 'system'].filter(k => refsTo(k).length);
|
|
1562
|
+
if (hoistable.length && !this.inline) {
|
|
1563
|
+
return skip('enqueueActions reads event/self/system shadowed outside');
|
|
1564
|
+
}
|
|
1565
|
+
const assigns = calls.some(c => c.kind === 'assign' || c.kind === 'enqueue' && c.args[0] && this.isAssign(c.args[0]));
|
|
1566
|
+
const contextRefs = refsTo('context');
|
|
1567
|
+
const needSnapshot = contextRefs.length > 0 && (assigns || br.assigned);
|
|
1568
|
+
for (const k of hoistable) this.hoist.add(k);
|
|
1569
|
+
if (contextRefs.length && !needSnapshot) {
|
|
1570
|
+
if (!this.inline) {
|
|
1571
|
+
return skip('enqueueActions reads `context` shadowed outside');
|
|
1572
|
+
}
|
|
1573
|
+
this.hoist.add('context');
|
|
1574
|
+
}
|
|
1575
|
+
const fnText = fn.getText();
|
|
1576
|
+
let snapshot = 'contextBefore';
|
|
1577
|
+
for (let i = 2; new RegExp(`\\b${snapshot}\\b`).test(fnText); i++) {
|
|
1578
|
+
snapshot = `contextBefore${i}`;
|
|
1579
|
+
}
|
|
1580
|
+
const snapshotValue = br.assigned ? 'context' : 'args.context';
|
|
1581
|
+
if (assigns) {
|
|
1582
|
+
br.assigned = true;
|
|
1583
|
+
br.forcePatch = true;
|
|
1584
|
+
this.threaded = true;
|
|
1585
|
+
}
|
|
1586
|
+
const replacements = [];
|
|
1587
|
+
if (needSnapshot) {
|
|
1588
|
+
for (const r of contextRefs) {
|
|
1589
|
+
replacements.push({
|
|
1590
|
+
node: r,
|
|
1591
|
+
text: Node.isShorthandPropertyAssignment(r.getParent()) ? `context: ${snapshot}` : snapshot
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
// Source text copied into enqueue/check lowering sees the renames too.
|
|
1596
|
+
const savedRenames = this.renames;
|
|
1597
|
+
this.renames = [...savedRenames, ...replacements];
|
|
1598
|
+
for (const {
|
|
1599
|
+
call,
|
|
1600
|
+
kind,
|
|
1601
|
+
args
|
|
1602
|
+
} of calls) {
|
|
1603
|
+
if (kind === 'check') {
|
|
1604
|
+
const cb = new Branch();
|
|
1605
|
+
cb.argsOverride = needSnapshot ? `{ ...args, context: ${snapshot} }` : snapshotValue === 'context' ? '{ ...args, context }' : 'args';
|
|
1606
|
+
const saved = this.inline;
|
|
1607
|
+
if (needSnapshot) this.inline = false;
|
|
1608
|
+
try {
|
|
1609
|
+
const g = this.guard(args[0], cb);
|
|
1610
|
+
replacements.push({
|
|
1611
|
+
node: call,
|
|
1612
|
+
text: g.atomic ? g.text : `(${g.text})`
|
|
1613
|
+
});
|
|
1614
|
+
} finally {
|
|
1615
|
+
this.inline = saved;
|
|
1616
|
+
}
|
|
1617
|
+
continue;
|
|
1618
|
+
}
|
|
1619
|
+
const sub = new Branch();
|
|
1620
|
+
sub.assigned = br.assigned;
|
|
1621
|
+
if (kind === 'enqueue') {
|
|
1622
|
+
if (args.length !== 1) return skip('enqueue(...) takes one action');
|
|
1623
|
+
this.action(args[0], sub, false);
|
|
1624
|
+
} else {
|
|
1625
|
+
this.creator(kind, args, sub);
|
|
1626
|
+
}
|
|
1627
|
+
if (sub.enqueued) br.enqueued = true;
|
|
1628
|
+
const lines = threadedLines(sub.entries);
|
|
1629
|
+
const statement = call.getParent();
|
|
1630
|
+
if (Node.isExpressionStatement(statement) || statement === fn) {
|
|
1631
|
+
const target = statement === fn ? call : statement;
|
|
1632
|
+
const indent = indentOf(target);
|
|
1633
|
+
replacements.push({
|
|
1634
|
+
node: target,
|
|
1635
|
+
text: lines.join(`\n${indent}`)
|
|
1636
|
+
});
|
|
1637
|
+
} else if (lines.length === 1 && lines[0].endsWith(';') && typeof sub.entries[0] === 'string') {
|
|
1638
|
+
replacements.push({
|
|
1639
|
+
node: call,
|
|
1640
|
+
text: lines[0].slice(0, -1)
|
|
1641
|
+
});
|
|
1642
|
+
} else {
|
|
1643
|
+
return skip('enqueue call is not a statement');
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
this.renames = savedRenames;
|
|
1647
|
+
const pre = [];
|
|
1648
|
+
if (needSnapshot) pre.push(`const ${snapshot} = ${snapshotValue};`);
|
|
1649
|
+
if (paramsName && fn.getDescendantsOfKind(SyntaxKind.Identifier).some(i => i.getText() === paramsName && i !== second.getNameNode())) {
|
|
1650
|
+
pre.push(`const ${paramsName} = ${this.paramsText ?? 'undefined'};`);
|
|
1651
|
+
}
|
|
1652
|
+
const statements = Node.isBlock(body) ? body.getStatements() : [body];
|
|
1653
|
+
const texts = statements.map(st => {
|
|
1654
|
+
const text = spliceText(st, replacements);
|
|
1655
|
+
const indent = indentOf(st);
|
|
1656
|
+
const lines = text.split('\n').map(l => l.startsWith(indent) ? l.slice(indent.length) : l);
|
|
1657
|
+
return Node.isBlock(body) || replacements.some(r => r.node === body) ? lines : [`${lines.join('\n')};`];
|
|
1658
|
+
});
|
|
1659
|
+
const declares = statements.some(st => Node.isVariableStatement(st) || Node.isFunctionDeclaration(st) || Node.isClassDeclaration(st));
|
|
1660
|
+
const flat = texts.flatMap(t => t.flatMap(l => l.split('\n')));
|
|
1661
|
+
if (pre.length || declares) {
|
|
1662
|
+
br.push('{', false);
|
|
1663
|
+
for (const line of [...pre, ...flat]) br.push(` ${line}`, false);
|
|
1664
|
+
br.push('}', false);
|
|
1665
|
+
} else {
|
|
1666
|
+
for (const line of flat) br.push(line, false);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
target(node, br) {
|
|
1670
|
+
const value = unwrap(node);
|
|
1671
|
+
if (Node.isStringLiteral(value)) {
|
|
1672
|
+
if (value.getLiteralText().startsWith('#')) {
|
|
1673
|
+
return skip(`send target '${value.getLiteralText()}' is a system id`);
|
|
1674
|
+
}
|
|
1675
|
+
return value.getText();
|
|
1676
|
+
}
|
|
1677
|
+
return this.dynamic(node, br);
|
|
1678
|
+
}
|
|
1679
|
+
namedAction(ref, params, br) {
|
|
1680
|
+
const {
|
|
1681
|
+
name
|
|
1682
|
+
} = ref;
|
|
1683
|
+
const def = this.ctx.impls.actions.get(name);
|
|
1684
|
+
if (!def) return skip(`action '${name}' is not defined in this file`);
|
|
1685
|
+
if (def.kind === 'inline') {
|
|
1686
|
+
this.inlineCheck(def, name);
|
|
1687
|
+
this.inlinedDefs.add(def);
|
|
1688
|
+
const saved = this.paramsText;
|
|
1689
|
+
this.paramsText = params ? this.dynamic(params, br) : undefined;
|
|
1690
|
+
try {
|
|
1691
|
+
return this.action(def.expr, br, false);
|
|
1692
|
+
} finally {
|
|
1693
|
+
this.paramsText = saved;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
if (def.kind === 'fn') {
|
|
1697
|
+
const p = params ? `, ${this.dynamic(params, br)}` : '';
|
|
1698
|
+
return br.push(`enq(args.actions${ref.access}, ${br.argsObj()}${p});`);
|
|
1699
|
+
}
|
|
1700
|
+
if (def.kind === 'assign') {
|
|
1701
|
+
const argsText = mentionsSpawn(def.assignArg) ? spawnArgs(br) : br.argsObj();
|
|
1702
|
+
const p = params ? `, ${this.dynamic(params, br, argsText)}` : '';
|
|
1703
|
+
this.ctx.assignDefs.add(def);
|
|
1704
|
+
return br.pushPatch({
|
|
1705
|
+
expr: `args.actions${ref.access}(${argsText}${p})`
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
return skip(`action '${name}' is not a plain function or assign`);
|
|
1709
|
+
}
|
|
1710
|
+
assign(node, br) {
|
|
1711
|
+
const value = unwrap(node);
|
|
1712
|
+
const argsText = mentionsSpawn(node) ? spawnArgs(br) : br.argsObj();
|
|
1713
|
+
if (Node.isObjectLiteralExpression(value)) {
|
|
1714
|
+
const props = value.getProperties().map(property => {
|
|
1715
|
+
if (Node.isShorthandPropertyAssignment(property)) {
|
|
1716
|
+
return this.t(property);
|
|
1717
|
+
}
|
|
1718
|
+
const key = getKeyText$2(property);
|
|
1719
|
+
if (!Node.isPropertyAssignment(property) || key === undefined) {
|
|
1720
|
+
return skip(`unsupported assign property '${property.getText()}'`);
|
|
1721
|
+
}
|
|
1722
|
+
return `${property.getNameNode().getText()}: ${this.dynamic(property.getInitializer(), br, argsText)}`;
|
|
1723
|
+
});
|
|
1724
|
+
return br.pushPatch({
|
|
1725
|
+
props: props.join(', ')
|
|
1726
|
+
});
|
|
1727
|
+
}
|
|
1728
|
+
if (isFunctionLike(value)) {
|
|
1729
|
+
const inline = this.inlined(value);
|
|
1730
|
+
const body = inline && unwrap(inline.body);
|
|
1731
|
+
if (body && Node.isObjectLiteralExpression(body)) {
|
|
1732
|
+
return br.pushPatch({
|
|
1733
|
+
props: body.getProperties().map(p => this.t(p)).join(', ')
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
if (inline) return br.pushPatch({
|
|
1737
|
+
expr: this.paren(inline.body)
|
|
1738
|
+
});
|
|
1739
|
+
return br.pushPatch({
|
|
1740
|
+
expr: `(${this.t(value)})(${argsText})`
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (Node.isIdentifier(value) && resolveIdentifier(value) === 'fn') {
|
|
1744
|
+
return br.pushPatch({
|
|
1745
|
+
expr: `${value.getText()}(${argsText})`
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
if (Node.isIdentifier(value) && isPlainObjectConst(value)) {
|
|
1749
|
+
return br.pushPatch({
|
|
1750
|
+
expr: value.getText()
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
return skip(`unsupported assign argument '${value.getText()}'`);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// ---- assembly -------------------------------------------------------------
|
|
1757
|
+
|
|
1758
|
+
/**
|
|
1759
|
+
* Header plus body. Hoisted names used once as `name.x` are written as
|
|
1760
|
+
* `args.name.x` instead. `guard` goes first when it needs no hoisted name.
|
|
1761
|
+
* `dropContext`: a function-scope `let context` exists, so `context` is not
|
|
1762
|
+
* destructured.
|
|
1763
|
+
*/
|
|
1764
|
+
assemble(body, dropContext, guard) {
|
|
1765
|
+
const all = guard ? [guard, ...body] : body;
|
|
1766
|
+
const names = [];
|
|
1767
|
+
let lines = all;
|
|
1768
|
+
for (const name of [...this.hoist].sort()) {
|
|
1769
|
+
if (dropContext && name === 'context') continue;
|
|
1770
|
+
// Property keys such as the returned `context:` are not references.
|
|
1771
|
+
const re = new RegExp(`(?<![\\w$])(?<!(?:^|[^.])\\.)(?<![{,]\\s*)${name}(?![\\w$])|(?<![\\w$])(?<!(?:^|[^.])\\.)${name}(?![\\w$])(?!\\s*:)`, 'g');
|
|
1772
|
+
const count = lines.reduce((n, l) => n + (l.match(re)?.length ?? 0), 0);
|
|
1773
|
+
if (count === 0) continue;
|
|
1774
|
+
const single = new RegExp(`(?<![\\w$])(?<!(?:^|[^.])\\.)${name}(?=\\.)`);
|
|
1775
|
+
if (count === 1 && lines.some(l => single.test(l))) {
|
|
1776
|
+
lines = lines.map(l => l.replace(single, `args.${name}`));
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
names.push(name);
|
|
1780
|
+
}
|
|
1781
|
+
const header = names.length ? [`const { ${names.join(', ')} } = args;`] : [];
|
|
1782
|
+
if (!guard) return [...header, ...lines];
|
|
1783
|
+
const [g, ...rest] = lines;
|
|
1784
|
+
const usesHoisted = names.some(n => new RegExp(`(?<![\\w$])(?<!(?:^|[^.])\\.)${n}(?![\\w$])`).test(g));
|
|
1785
|
+
return usesHoisted ? [...header, g, ...rest] : [g, ...header, ...rest];
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/** A reference to a named implementation: its name and how to access it. */
|
|
1790
|
+
|
|
1791
|
+
function nameRef(node) {
|
|
1792
|
+
if (!node) return undefined;
|
|
1793
|
+
const value = unwrap(node);
|
|
1794
|
+
const name = resolveStringConstant(value);
|
|
1795
|
+
if (name === undefined) return undefined;
|
|
1796
|
+
return Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) ? {
|
|
1797
|
+
name,
|
|
1798
|
+
access: access(name)
|
|
1799
|
+
} : {
|
|
1800
|
+
name,
|
|
1801
|
+
access: `[${value.getText()}]`
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
function getInit(object, key) {
|
|
1805
|
+
const property = object.getProperties().find(p => getKeyText$2(p) === key);
|
|
1806
|
+
return Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
1807
|
+
}
|
|
1808
|
+
function typeAndParams(object, what) {
|
|
1809
|
+
for (const property of object.getProperties()) {
|
|
1810
|
+
const key = getKeyText$2(property);
|
|
1811
|
+
if (!Node.isPropertyAssignment(property) || key !== 'type' && key !== 'params') {
|
|
1812
|
+
return skip(`unsupported ${what} object key '${property.getText()}'`);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const ref = nameRef(getInit(object, 'type'));
|
|
1816
|
+
if (!ref) return skip(`${what} object has no string 'type'`);
|
|
1817
|
+
return {
|
|
1818
|
+
ref,
|
|
1819
|
+
params: getInit(object, 'params')
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
function mentionsSpawn(node) {
|
|
1823
|
+
return /\bspawn\b/.test(node.getText());
|
|
1824
|
+
}
|
|
1825
|
+
function spawnArgs(br) {
|
|
1826
|
+
if (br.stringStop) {
|
|
1827
|
+
skip('an assign spawns after stopChild by id; child ids are not static');
|
|
1828
|
+
}
|
|
1829
|
+
br.spawnAssign = true;
|
|
1830
|
+
return br.assigned ? '{ ...args, context, spawn: enq.spawn }' : '{ ...args, spawn: enq.spawn }';
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** `//` and `/* *\/` comments directly before a node, as written. */
|
|
1834
|
+
function leadingComments(node) {
|
|
1835
|
+
const text = node.getSourceFile().getFullText();
|
|
1836
|
+
return (ts.getLeadingCommentRanges(text, node.getPos()) ?? []).map(range => text.slice(range.pos, range.end));
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// ---- transitions ------------------------------------------------------------
|
|
1840
|
+
|
|
1841
|
+
function hasLowerableKeys(object) {
|
|
1842
|
+
return Node.isObjectLiteralExpression(object) && object.getProperties().some(property => {
|
|
1843
|
+
const key = getKeyText$2(property);
|
|
1844
|
+
return key === 'actions' || key === 'guard' || key === 'cond';
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
function lowerBranch(fb, object) {
|
|
1848
|
+
const comments = [];
|
|
1849
|
+
let description;
|
|
1850
|
+
for (const property of object.getProperties()) {
|
|
1851
|
+
const key = getKeyText$2(property);
|
|
1852
|
+
if (key === 'cond') {
|
|
1853
|
+
return skip('`cond` is a v4 key that v5 rejects in development and ignores in production');
|
|
1854
|
+
}
|
|
1855
|
+
if (!key || !TRANSITION_KEYS.has(key) || !Node.isPropertyAssignment(property)) {
|
|
1856
|
+
return skip(`unsupported transition key '${property.getText()}'`);
|
|
1857
|
+
}
|
|
1858
|
+
comments.push(...leadingComments(property));
|
|
1859
|
+
if (key === 'description') {
|
|
1860
|
+
const value = unwrap(property.getInitializer());
|
|
1861
|
+
description = Node.isStringLiteral(value) || Node.isNoSubstitutionTemplateLiteral(value) ? value.getLiteralText() : value.getText();
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
const br = new Branch();
|
|
1865
|
+
const guardNode = getInit(object, 'guard');
|
|
1866
|
+
const guard = guardNode ? fb.guard(guardNode, br) : undefined;
|
|
1867
|
+
fb.actions(getInit(object, 'actions'), br);
|
|
1868
|
+
const returnFields = [];
|
|
1869
|
+
for (const key of ['target', 'reenter', 'meta']) {
|
|
1870
|
+
const value = getInit(object, key);
|
|
1871
|
+
if (value) returnFields.push(`${key}: ${value.getText()}`);
|
|
1872
|
+
}
|
|
1873
|
+
const {
|
|
1874
|
+
lines,
|
|
1875
|
+
context,
|
|
1876
|
+
threaded
|
|
1877
|
+
} = br.finish(fb);
|
|
1878
|
+
if (context) returnFields.push(context);
|
|
1879
|
+
return {
|
|
1880
|
+
guard,
|
|
1881
|
+
description,
|
|
1882
|
+
comments,
|
|
1883
|
+
lines,
|
|
1884
|
+
enqueued: br.enqueued,
|
|
1885
|
+
returnFields,
|
|
1886
|
+
threaded
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
const descriptionComments = description => description === undefined ? [] : description.split(/\r?\n/).map(line => `// ${line}`.trimEnd());
|
|
1890
|
+
const returnStatement = fields => fields.length ? `return { ${fields.join(', ')} };` : 'return {};';
|
|
1891
|
+
const DESCRIPTION_NOTE = 'dropped a transition `description` (kept as a comment); v6 transition functions carry no description';
|
|
1892
|
+
/** Rewrites `const x = raise(...)` (or another creator) as `(args, enq) => {...}`. */
|
|
1893
|
+
function lowerConstCreator(ctx, call) {
|
|
1894
|
+
try {
|
|
1895
|
+
const fb = new FunctionBuilder(ctx, call);
|
|
1896
|
+
const br = new Branch();
|
|
1897
|
+
fb.actions(call, br);
|
|
1898
|
+
const {
|
|
1899
|
+
lines,
|
|
1900
|
+
context
|
|
1901
|
+
} = br.finish(fb);
|
|
1902
|
+
if (context) return undefined;
|
|
1903
|
+
return ['(args, enq) => {', ...fb.assemble(lines, false).map(l => ` ${l}`), '}'].join('\n');
|
|
1904
|
+
} catch (error) {
|
|
1905
|
+
if (error instanceof Skip) return undefined;
|
|
1906
|
+
throw error;
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
/** Lowers a transition object or array to a function body. */
|
|
1911
|
+
function lowerTransition(ctx, value) {
|
|
1912
|
+
const fb = new FunctionBuilder(ctx, value);
|
|
1913
|
+
const notes = [];
|
|
1914
|
+
if (Node.isObjectLiteralExpression(value)) {
|
|
1915
|
+
const b = lowerBranch(fb, value);
|
|
1916
|
+
if (b.description !== undefined) notes.push(DESCRIPTION_NOTE);
|
|
1917
|
+
const guardLine = b.guard ? `if (!${b.guard.atomic ? b.guard.text : `(${b.guard.text})`}) return;` : undefined;
|
|
1918
|
+
const body = [...b.lines];
|
|
1919
|
+
if (b.returnFields.length || !b.enqueued) {
|
|
1920
|
+
body.push(returnStatement(b.returnFields));
|
|
1921
|
+
}
|
|
1922
|
+
return {
|
|
1923
|
+
body: [...b.comments, ...fb.assemble(body, b.threaded, guardLine)],
|
|
1924
|
+
above: descriptionComments(b.description),
|
|
1925
|
+
notes,
|
|
1926
|
+
inlined: fb.inlinedDefs,
|
|
1927
|
+
constRefs: fb.constRefs
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
if (!Node.isArrayLiteralExpression(value)) {
|
|
1931
|
+
return skip('transition is not an object or array');
|
|
1932
|
+
}
|
|
1933
|
+
const body = [];
|
|
1934
|
+
const elements = value.getElements();
|
|
1935
|
+
for (let i = 0; i < elements.length; i++) {
|
|
1936
|
+
const element = unwrap(elements[i]);
|
|
1937
|
+
if (!Node.isObjectLiteralExpression(element)) {
|
|
1938
|
+
return skip('transition array element is not an object literal');
|
|
1939
|
+
}
|
|
1940
|
+
const b = lowerBranch(fb, element);
|
|
1941
|
+
if (b.description !== undefined) notes.push(DESCRIPTION_NOTE);
|
|
1942
|
+
const inner = [...leadingComments(elements[i]), ...descriptionComments(b.description), ...b.comments, ...b.lines, returnStatement(b.returnFields)];
|
|
1943
|
+
if (!b.guard) {
|
|
1944
|
+
if (b.threaded) {
|
|
1945
|
+
body.push('{', ...inner.map(line => ` ${line}`), '}');
|
|
1946
|
+
} else {
|
|
1947
|
+
body.push(...inner);
|
|
1948
|
+
}
|
|
1949
|
+
if (i < elements.length - 1) {
|
|
1950
|
+
notes.push(`dropped ${elements.length - 1 - i} unreachable transition candidate(s) after an unguarded one`);
|
|
1951
|
+
}
|
|
1952
|
+
break;
|
|
1953
|
+
}
|
|
1954
|
+
body.push(`if (${b.guard.text}) {`, ...inner.map(line => ` ${line}`), '}');
|
|
1955
|
+
}
|
|
1956
|
+
return {
|
|
1957
|
+
body: fb.assemble(body, false),
|
|
1958
|
+
above: [],
|
|
1959
|
+
notes,
|
|
1960
|
+
inlined: fb.inlinedDefs,
|
|
1961
|
+
constRefs: fb.constRefs
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** Lowers an `entry`/`exit` value to a function body. */
|
|
1966
|
+
function lowerEntryExit(ctx, value) {
|
|
1967
|
+
const fb = new FunctionBuilder(ctx, value);
|
|
1968
|
+
const br = new Branch();
|
|
1969
|
+
fb.actions(value, br);
|
|
1970
|
+
const {
|
|
1971
|
+
lines,
|
|
1972
|
+
context,
|
|
1973
|
+
threaded
|
|
1974
|
+
} = br.finish(fb);
|
|
1975
|
+
const body = [...lines];
|
|
1976
|
+
if (context) body.push(`return { ${context} };`);
|
|
1977
|
+
return {
|
|
1978
|
+
body: fb.assemble(body, threaded),
|
|
1979
|
+
above: [],
|
|
1980
|
+
notes: [],
|
|
1981
|
+
inlined: fb.inlinedDefs,
|
|
1982
|
+
constRefs: fb.constRefs
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/** Rewrites a named `assign(...)` definition as a patch-returning function. */
|
|
1987
|
+
function lowerAssignDefinition(def) {
|
|
1988
|
+
const arg = unwrap(def.assignArg);
|
|
1989
|
+
if (Node.isIdentifier(arg) && isPlainObjectConst(arg)) {
|
|
1990
|
+
return `() => ${arg.getText()}`;
|
|
1991
|
+
}
|
|
1992
|
+
if (isFunctionLike(arg)) return def.assignArg.getText();
|
|
1993
|
+
if (Node.isIdentifier(arg) && resolveIdentifier(arg) === 'fn') {
|
|
1994
|
+
return arg.getText();
|
|
1995
|
+
}
|
|
1996
|
+
if (!Node.isObjectLiteralExpression(arg)) return undefined;
|
|
1997
|
+
const props = arg.getProperties();
|
|
1998
|
+
if (props.some(p => !Node.isPropertyAssignment(p) && !Node.isShorthandPropertyAssignment(p))) return undefined;
|
|
1999
|
+
const inlineOk = outerReferences(arg, new Set([...HOISTABLE, 'args', 'params'])).size === 0;
|
|
2000
|
+
const names = new Set();
|
|
2001
|
+
const inlined = props.map(p => {
|
|
2002
|
+
if (!Node.isPropertyAssignment(p)) return p.getText();
|
|
2003
|
+
const value = unwrap(p.getInitializer());
|
|
2004
|
+
if (!isFunctionLike(value)) return p.getText();
|
|
2005
|
+
const inline = inlineOk ? inlineable(value) : undefined;
|
|
2006
|
+
if (!inline) return undefined;
|
|
2007
|
+
inline.names.forEach(n => names.add(n));
|
|
2008
|
+
return `${p.getNameNode().getText()}: ${inline.body.getText()}`;
|
|
2009
|
+
});
|
|
2010
|
+
if (inlined.every(p => p !== undefined)) {
|
|
2011
|
+
const param = names.size ? `{ ${[...names].sort().join(', ')} }` : '';
|
|
2012
|
+
return `(${param}) => ({ ${inlined.join(', ')} })`;
|
|
2013
|
+
}
|
|
2014
|
+
if (outerReferences(arg, new Set(['args', 'params'])).size) return undefined;
|
|
2015
|
+
const generic = props.map(p => {
|
|
2016
|
+
if (!Node.isPropertyAssignment(p)) return p.getText();
|
|
2017
|
+
const value = unwrap(p.getInitializer());
|
|
2018
|
+
if (!isFunctionLike(value)) return p.getText();
|
|
2019
|
+
return `${p.getNameNode().getText()}: (${value.getText()})(args, params)`;
|
|
2020
|
+
});
|
|
2021
|
+
return `(args, params) => ({ ${generic.join(', ')} })`;
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* A module-level `const` object literal with no function, spread or method
|
|
2026
|
+
* members: `assign(x)` then merges exactly `x`.
|
|
2027
|
+
*/
|
|
2028
|
+
function isPlainObjectConst(id) {
|
|
2029
|
+
const decls = id.getSymbol()?.getDeclarations() ?? [];
|
|
2030
|
+
if (decls.length !== 1 || !Node.isVariableDeclaration(decls[0])) return false;
|
|
2031
|
+
const decl = decls[0];
|
|
2032
|
+
const list = decl.getParent();
|
|
2033
|
+
const statement = decl.getVariableStatement();
|
|
2034
|
+
if (!Node.isVariableDeclarationList(list) || !(list.getFlags() & 2) || !statement || !Node.isSourceFile(statement.getParent())) return false;
|
|
2035
|
+
const init = decl.getInitializer();
|
|
2036
|
+
const value = init && unwrap(init);
|
|
2037
|
+
return !!value && Node.isObjectLiteralExpression(value) && value.getProperties().every(p => Node.isPropertyAssignment(p) && !isFunctionLike(unwrap(p.getInitializer())));
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
const IMPLEMENTATION_KEYS = new Set(['actions', 'guards', 'actors', 'delays']);
|
|
2041
|
+
|
|
2042
|
+
/** Action creators and helpers this transform can leave unreferenced. */
|
|
2043
|
+
const REMOVABLE_IMPORTS = new Set(['assign', 'raise', 'sendTo', 'sendParent', 'forwardTo', 'emit', 'log', 'cancel', 'stopChild', 'stop', 'spawnChild', 'enqueueActions', 'and', 'or', 'not', 'stateIn', 'fromPromise']);
|
|
2044
|
+
function applyEdits(sourceFile, edits) {
|
|
2045
|
+
if (!edits.length) return false;
|
|
2046
|
+
// Keep outermost edits only; nested ones are part of a replaced range.
|
|
2047
|
+
const sorted = [...edits].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
2048
|
+
const kept = [];
|
|
2049
|
+
for (const edit of sorted) {
|
|
2050
|
+
const last = kept[kept.length - 1];
|
|
2051
|
+
if (last && edit.start < last.end) continue;
|
|
2052
|
+
kept.push(edit);
|
|
2053
|
+
}
|
|
2054
|
+
let text = sourceFile.getFullText();
|
|
2055
|
+
for (const edit of kept.reverse()) {
|
|
2056
|
+
text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
|
|
2057
|
+
}
|
|
2058
|
+
sourceFile.replaceWithText(text);
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
function indentAt(sourceFile, pos) {
|
|
2062
|
+
const text = sourceFile.getFullText();
|
|
2063
|
+
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
|
|
2064
|
+
return /^[ \t]*/.exec(text.slice(lineStart))[0];
|
|
2065
|
+
}
|
|
2066
|
+
function where(sourceFile, node) {
|
|
2067
|
+
const {
|
|
2068
|
+
line,
|
|
2069
|
+
column
|
|
2070
|
+
} = sourceFile.getLineAndColumnAtPos(node.getStart());
|
|
2071
|
+
return `${sourceFile.getFilePath()}:${line}:${column}`;
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/** Appends property texts to an object literal, keeping its layout. */
|
|
2075
|
+
function appendProperties(sourceFile, object, texts) {
|
|
2076
|
+
const last = object.getProperties().at(-1);
|
|
2077
|
+
if (!last) {
|
|
2078
|
+
const indent = indentAt(sourceFile, object.getStart());
|
|
2079
|
+
return {
|
|
2080
|
+
start: object.getStart() + 1,
|
|
2081
|
+
end: object.getEnd() - 1,
|
|
2082
|
+
text: `\n${texts.map(t => `${indent} ${t}`).join(',\n')}\n${indent}`
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
const indent = indentAt(sourceFile, last.getStart());
|
|
2086
|
+
return {
|
|
2087
|
+
start: last.getEnd(),
|
|
2088
|
+
end: last.getEnd(),
|
|
2089
|
+
text: texts.map(t => `,\n${indent}${t}`).join('')
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
// ---- F: move createMachine's second argument into the config -----------------
|
|
2094
|
+
|
|
2095
|
+
function moveImplementations(sourceFile, notes) {
|
|
2096
|
+
const edits = [];
|
|
2097
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
2098
|
+
const expr = call.getExpression();
|
|
2099
|
+
const name = Node.isIdentifier(expr) ? expr.getText() : Node.isPropertyAccessExpression(expr) ? expr.getName() : undefined;
|
|
2100
|
+
const args = call.getArguments();
|
|
2101
|
+
if (name !== 'createMachine' || args.length !== 2 || !isXstateMachineCall(call)) continue;
|
|
2102
|
+
const [config, impls] = args.map(unwrap);
|
|
2103
|
+
if (!Node.isObjectLiteralExpression(config) || !Node.isObjectLiteralExpression(impls)) {
|
|
2104
|
+
notes.push(`${where(sourceFile, args[1])} — left the second createMachine argument: not an object literal`);
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
const plan = [];
|
|
2108
|
+
const newKeys = [];
|
|
2109
|
+
let ok = true;
|
|
2110
|
+
for (const property of impls.getProperties()) {
|
|
2111
|
+
const key = getKeyText$2(property);
|
|
2112
|
+
if (!key || !IMPLEMENTATION_KEYS.has(key) || !(Node.isPropertyAssignment(property) || Node.isShorthandPropertyAssignment(property))) {
|
|
2113
|
+
ok = false;
|
|
2114
|
+
break;
|
|
2115
|
+
}
|
|
2116
|
+
const existing = getProperty$1(config, key);
|
|
2117
|
+
if (!existing) {
|
|
2118
|
+
newKeys.push(property.getText());
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
const value = Node.isPropertyAssignment(property) ? unwrap(property.getInitializer()) : undefined;
|
|
2122
|
+
const target = unwrap(existing);
|
|
2123
|
+
if (!value || !Node.isObjectLiteralExpression(value) || !Node.isObjectLiteralExpression(target)) {
|
|
2124
|
+
ok = false;
|
|
2125
|
+
break;
|
|
2126
|
+
}
|
|
2127
|
+
const texts = value.getProperties().map(p => p.getText());
|
|
2128
|
+
if (texts.length) plan.push(appendProperties(sourceFile, target, texts));
|
|
2129
|
+
}
|
|
2130
|
+
if (!ok) {
|
|
2131
|
+
notes.push(`${where(sourceFile, args[1])} — left the second createMachine argument: keys other than actions/guards/actors/delays, or a non-literal map`);
|
|
2132
|
+
continue;
|
|
2133
|
+
}
|
|
2134
|
+
if (newKeys.length) plan.push(appendProperties(sourceFile, config, newKeys));
|
|
2135
|
+
plan.push({
|
|
2136
|
+
start: args[0].getEnd(),
|
|
2137
|
+
end: args[1].getEnd(),
|
|
2138
|
+
text: ''
|
|
2139
|
+
});
|
|
2140
|
+
edits.push(...plan);
|
|
2141
|
+
notes.push(`${where(sourceFile, args[1])} — moved the second createMachine argument into the config`);
|
|
2142
|
+
}
|
|
2143
|
+
return applyEdits(sourceFile, edits);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// ---- A–E: transitions and entry/exit --------------------------------------------
|
|
2147
|
+
|
|
2148
|
+
function render(sourceFile, property, value, lowered) {
|
|
2149
|
+
const indent = indentAt(sourceFile, property.getStart());
|
|
2150
|
+
const text = lowered.body.join('\n');
|
|
2151
|
+
const usesEnq = /\benq\b/.test(text);
|
|
2152
|
+
const params = usesEnq ? '(args, enq)' : /\bargs\b/.test(text) ? '(args)' : '()';
|
|
2153
|
+
const fn = [`${params} => {`, ...lowered.body.map(line => `${indent} ${line}`), `${indent}}`].join('\n');
|
|
2154
|
+
if (!lowered.above.length || !Node.isPropertyAssignment(property)) {
|
|
2155
|
+
return {
|
|
2156
|
+
start: value.getStart(),
|
|
2157
|
+
end: value.getEnd(),
|
|
2158
|
+
text: fn
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
return {
|
|
2162
|
+
start: property.getStart(),
|
|
2163
|
+
end: value.getEnd(),
|
|
2164
|
+
text: `${lowered.above.join(`\n${indent}`)}\n${indent}${property.getNameNode().getText()}: ${fn}`
|
|
2165
|
+
};
|
|
2166
|
+
}
|
|
2167
|
+
function lowerMachines(sourceFile, notes) {
|
|
2168
|
+
// A module-level action const is rewritten only when every reference to it
|
|
2169
|
+
// is a call site that got lowered; otherwise plan again without it.
|
|
2170
|
+
const disabledConsts = new Set();
|
|
2171
|
+
for (;;) {
|
|
2172
|
+
const plan = planMachines(sourceFile, disabledConsts);
|
|
2173
|
+
if (plan.edits) {
|
|
2174
|
+
notes.push(...plan.notes);
|
|
2175
|
+
return applyEdits(sourceFile, plan.edits);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
function planMachines(sourceFile, disabledConsts) {
|
|
2180
|
+
const notes = [];
|
|
2181
|
+
const xs = getXstateImports(sourceFile);
|
|
2182
|
+
const edits = [];
|
|
2183
|
+
const assignDefs = new Set();
|
|
2184
|
+
const constRefs = new Map();
|
|
2185
|
+
const inlined = new Map();
|
|
2186
|
+
const attempt = (property, value, lower) => {
|
|
2187
|
+
try {
|
|
2188
|
+
const lowered = lower();
|
|
2189
|
+
edits.push(render(sourceFile, property, value, lowered));
|
|
2190
|
+
for (const def of lowered.inlined) {
|
|
2191
|
+
inlined.set(def.property, implName(def));
|
|
2192
|
+
}
|
|
2193
|
+
for (const [decl, ids] of lowered.constRefs) {
|
|
2194
|
+
const set = constRefs.get(decl) ?? new Set();
|
|
2195
|
+
ids.forEach(id => set.add(id));
|
|
2196
|
+
constRefs.set(decl, set);
|
|
2197
|
+
}
|
|
2198
|
+
for (const note of lowered.notes) {
|
|
2199
|
+
notes.push(`${where(sourceFile, value)} — ${note}`);
|
|
2200
|
+
}
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
if (!(error instanceof Skip)) throw error;
|
|
2203
|
+
notes.push(`${where(sourceFile, value)} — not lowered: ${error.message}`);
|
|
2204
|
+
}
|
|
2205
|
+
};
|
|
2206
|
+
const provided = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter(call => {
|
|
2207
|
+
const callee = call.getExpression();
|
|
2208
|
+
return Node.isIdentifier(callee) && callee.getText() === 'provide' || Node.isPropertyAccessExpression(callee) && callee.getName() === 'provide';
|
|
2209
|
+
}).map(call => call.getArguments().map(a => a.getText()).join(','));
|
|
2210
|
+
const mentions = (text, name) => new RegExp(`(^|[^\\w$])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w$])`).test(text);
|
|
2211
|
+
for (const config of findMachineConfigObjects(sourceFile)) {
|
|
2212
|
+
if (!isXstateMachineCall(config.getParentOrThrow())) {
|
|
2213
|
+
notes.push(`${where(sourceFile, config)} — not lowered: the machine call does not resolve to an 'xstate' import`);
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
const impls = collectImplementations(config, xs);
|
|
2217
|
+
const rewrites = new Map();
|
|
2218
|
+
for (const [name, def] of impls.actions) {
|
|
2219
|
+
if (def.kind !== 'assign') continue;
|
|
2220
|
+
if (provided.some(text => mentions(text, name))) {
|
|
2221
|
+
// `.provide({ actions: { name } })` elsewhere would still pass a v5
|
|
2222
|
+
// assign where call sites expect a patch function.
|
|
2223
|
+
def.kind = 'other';
|
|
2224
|
+
notes.push(`${where(sourceFile, def.property)} — not lowered: '${name}' is overridden through provide(...)`);
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
const text = lowerAssignDefinition(def);
|
|
2228
|
+
if (text === undefined) def.kind = 'other';else rewrites.set(def, text);
|
|
2229
|
+
}
|
|
2230
|
+
const ctx = {
|
|
2231
|
+
xs,
|
|
2232
|
+
impls,
|
|
2233
|
+
assignDefs,
|
|
2234
|
+
disabledConsts
|
|
2235
|
+
};
|
|
2236
|
+
|
|
2237
|
+
/** Literal behind an in-file const reference, when safe to edit in place. */
|
|
2238
|
+
const resolve = value => {
|
|
2239
|
+
if (!isIndirect(value)) return value;
|
|
2240
|
+
const target = derefConfig(value);
|
|
2241
|
+
if (!target) return value;
|
|
2242
|
+
if (!onlyConfigReferences(target.decl)) return undefined;
|
|
2243
|
+
return target.node;
|
|
2244
|
+
};
|
|
2245
|
+
const transition = (property, value) => {
|
|
2246
|
+
if (!value) return;
|
|
2247
|
+
if (isIndirect(value)) {
|
|
2248
|
+
const target = resolve(value);
|
|
2249
|
+
if (!target || target === value || isFunctionLike(target)) return;
|
|
2250
|
+
// Lowered in place inside the const: replace the literal only.
|
|
2251
|
+
return transition(target, target);
|
|
2252
|
+
}
|
|
2253
|
+
const v = unwrap(value);
|
|
2254
|
+
const candidates = Node.isArrayLiteralExpression(v) ? v.getElements().map(unwrap) : [v];
|
|
2255
|
+
if (!candidates.some(hasLowerableKeys)) {
|
|
2256
|
+
if (Node.isArrayLiteralExpression(v)) collapseArray(v, candidates);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
attempt(property, value, () => lowerTransition(ctx, v));
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
// v5 takes the first enabled candidate; with no guards that is always the
|
|
2263
|
+
// first one, so the rest are unreachable.
|
|
2264
|
+
const collapseArray = (array, candidates) => {
|
|
2265
|
+
const [first] = candidates;
|
|
2266
|
+
if (!first || !candidates.every(c => Node.isObjectLiteralExpression(c) || Node.isStringLiteral(c) || Node.isNoSubstitutionTemplateLiteral(c))) return;
|
|
2267
|
+
edits.push({
|
|
2268
|
+
start: array.getStart(),
|
|
2269
|
+
end: array.getEnd(),
|
|
2270
|
+
text: first.getText()
|
|
2271
|
+
});
|
|
2272
|
+
if (candidates.length > 1) {
|
|
2273
|
+
notes.push(`${where(sourceFile, array)} — collapsed an unguarded transition array to its first candidate; v5 never reaches the other ${candidates.length - 1}`);
|
|
2274
|
+
}
|
|
2275
|
+
};
|
|
2276
|
+
const transitionMap = map => {
|
|
2277
|
+
const value = map && resolve(map);
|
|
2278
|
+
if (!value || !Node.isObjectLiteralExpression(value)) return;
|
|
2279
|
+
for (const property of value.getProperties()) {
|
|
2280
|
+
if (Node.isPropertyAssignment(property)) {
|
|
2281
|
+
transition(property, property.getInitializer());
|
|
2282
|
+
} else if (Node.isSpreadAssignment(property)) {
|
|
2283
|
+
transitionMap(property);
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
};
|
|
2287
|
+
const visitState = node => {
|
|
2288
|
+
const state = node && resolve(node);
|
|
2289
|
+
if (!state || !Node.isObjectLiteralExpression(state)) return;
|
|
2290
|
+
for (const property of state.getProperties()) {
|
|
2291
|
+
if (Node.isSpreadAssignment(property)) {
|
|
2292
|
+
visitState(property);
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
const key = getKeyText$2(property);
|
|
2296
|
+
if (!key || !Node.isPropertyAssignment(property)) continue;
|
|
2297
|
+
const value = property.getInitializer();
|
|
2298
|
+
if (!value) continue;
|
|
2299
|
+
switch (key) {
|
|
2300
|
+
case 'entry':
|
|
2301
|
+
case 'exit':
|
|
2302
|
+
{
|
|
2303
|
+
const target = resolve(value);
|
|
2304
|
+
if (target && !isFunctionLike(unwrap(target))) {
|
|
2305
|
+
const at = target === value ? property : target;
|
|
2306
|
+
attempt(at, target, () => lowerEntryExit(ctx, target));
|
|
2307
|
+
}
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
case 'on':
|
|
2311
|
+
case 'after':
|
|
2312
|
+
transitionMap(value);
|
|
2313
|
+
break;
|
|
2314
|
+
case 'always':
|
|
2315
|
+
case 'onDone':
|
|
2316
|
+
case 'onError':
|
|
2317
|
+
case 'onSnapshot':
|
|
2318
|
+
transition(property, value);
|
|
2319
|
+
break;
|
|
2320
|
+
case 'invoke':
|
|
2321
|
+
{
|
|
2322
|
+
const list = resolve(value);
|
|
2323
|
+
const invokes = !list ? [] : Node.isArrayLiteralExpression(list) ? list.getElements().map(resolve) : [list];
|
|
2324
|
+
for (const invoke of invokes) {
|
|
2325
|
+
if (!invoke || !Node.isObjectLiteralExpression(invoke)) continue;
|
|
2326
|
+
for (const p of invoke.getProperties()) {
|
|
2327
|
+
const k = getKeyText$2(p);
|
|
2328
|
+
if (Node.isPropertyAssignment(p) && (k === 'onDone' || k === 'onError' || k === 'onSnapshot')) {
|
|
2329
|
+
transition(p, p.getInitializer());
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
break;
|
|
2334
|
+
}
|
|
2335
|
+
case 'states':
|
|
2336
|
+
visitStates(value);
|
|
2337
|
+
break;
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
};
|
|
2341
|
+
const visitStates = node => {
|
|
2342
|
+
const states = resolve(node);
|
|
2343
|
+
if (!states || !Node.isObjectLiteralExpression(states)) return;
|
|
2344
|
+
for (const child of states.getProperties()) {
|
|
2345
|
+
if (Node.isPropertyAssignment(child)) {
|
|
2346
|
+
visitState(child.getInitializer());
|
|
2347
|
+
} else if (Node.isShorthandPropertyAssignment(child)) {
|
|
2348
|
+
visitState(child);
|
|
2349
|
+
} else if (Node.isSpreadAssignment(child)) {
|
|
2350
|
+
visitStates(child);
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
visitState(config);
|
|
2355
|
+
for (const [def, text] of rewrites) {
|
|
2356
|
+
if (def.constDecl && Node.isVariableDeclaration(def.constDecl)) {
|
|
2357
|
+
const init = def.constDecl.getInitializer();
|
|
2358
|
+
edits.push({
|
|
2359
|
+
start: init.getStart(),
|
|
2360
|
+
end: init.getEnd(),
|
|
2361
|
+
text
|
|
2362
|
+
});
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
if (!Node.isPropertyAssignment(def.property)) continue;
|
|
2366
|
+
const init = def.property.getInitializer();
|
|
2367
|
+
edits.push({
|
|
2368
|
+
start: init.getStart(),
|
|
2369
|
+
end: init.getEnd(),
|
|
2370
|
+
text
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
let retry = false;
|
|
2375
|
+
for (const [decl, ids] of constRefs) {
|
|
2376
|
+
const declaration = decl;
|
|
2377
|
+
const nameNode = declaration.getNameNode();
|
|
2378
|
+
const symbol = nameNode.getSymbol();
|
|
2379
|
+
const unlowered = sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).some(id => id !== nameNode && id.getText() === nameNode.getText() && id.getSymbol() === symbol && !ids.has(id));
|
|
2380
|
+
const call = unwrap(declaration.getInitializer());
|
|
2381
|
+
const text = unlowered ? undefined : xstateCallee(call, xs) === 'assign' ? call.getArguments().length !== 1 ? undefined : lowerAssignDefinition({
|
|
2382
|
+
kind: 'assign',
|
|
2383
|
+
property: decl,
|
|
2384
|
+
assignArg: call.getArguments()[0]
|
|
2385
|
+
}) : lowerConstCreator({
|
|
2386
|
+
xs,
|
|
2387
|
+
impls: {
|
|
2388
|
+
actions: new Map(),
|
|
2389
|
+
guards: new Map()
|
|
2390
|
+
},
|
|
2391
|
+
assignDefs,
|
|
2392
|
+
disabledConsts
|
|
2393
|
+
}, call);
|
|
2394
|
+
if (text === undefined) {
|
|
2395
|
+
disabledConsts.add(decl);
|
|
2396
|
+
retry = true;
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
const init = declaration.getInitializer();
|
|
2400
|
+
edits.push({
|
|
2401
|
+
start: init.getStart(),
|
|
2402
|
+
end: init.getEnd(),
|
|
2403
|
+
text
|
|
2404
|
+
});
|
|
2405
|
+
notes.push(`${where(sourceFile, decl)} — rewrote '${nameNode.getText()}' as a function its call sites invoke`);
|
|
2406
|
+
}
|
|
2407
|
+
if (retry) return {
|
|
2408
|
+
notes
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
// Definitions only referenced from other removed definitions go too.
|
|
2412
|
+
const removed = [];
|
|
2413
|
+
for (let grew = true; grew;) {
|
|
2414
|
+
grew = false;
|
|
2415
|
+
const ranges = [...edits, ...removed.map(p => ({
|
|
2416
|
+
start: p.getStart(),
|
|
2417
|
+
end: p.getEnd()
|
|
2418
|
+
}))];
|
|
2419
|
+
for (const [property, name] of inlined) {
|
|
2420
|
+
if (removed.includes(property)) continue;
|
|
2421
|
+
if (!hasOtherReferences(sourceFile, name, property, ranges)) {
|
|
2422
|
+
removed.push(property);
|
|
2423
|
+
grew = true;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
for (const [property, name] of inlined) {
|
|
2428
|
+
if (!removed.includes(property)) {
|
|
2429
|
+
notes.push(`${where(sourceFile, property)} — kept '${name}': referenced where it was not inlined`);
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
edits.push(...removeProperties(removed));
|
|
2433
|
+
return {
|
|
2434
|
+
edits,
|
|
2435
|
+
notes
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function implName(def) {
|
|
2439
|
+
const name = def.property.getNameNode();
|
|
2440
|
+
return Node.isComputedPropertyName(name) ? resolveStringConstant(name.getExpression()) ?? '' : getKeyText$2(def.property) ?? '';
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
/** Whether `name` still appears outside `def` and the replaced ranges. */
|
|
2444
|
+
function hasOtherReferences(sourceFile, name, def, edits) {
|
|
2445
|
+
const replaced = node => node.getStart() >= def.getStart() && node.getEnd() <= def.getEnd() || edits.some(e => node.getStart() >= e.start && node.getEnd() <= e.end);
|
|
2446
|
+
return sourceFile.forEachDescendant(node => {
|
|
2447
|
+
const matches = (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) && node.getLiteralText() === name || Node.isIdentifier(node) && node.getText() === name || Node.isPropertyAccessExpression(node) && resolveStringConstant(node) === name;
|
|
2448
|
+
return matches && !replaced(node) ? true : undefined;
|
|
2449
|
+
}) ?? false;
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** Deletes object literal properties with their separating commas. */
|
|
2453
|
+
function removeProperties(properties) {
|
|
2454
|
+
const edits = [];
|
|
2455
|
+
const objects = new Set(properties.map(p => p.getParentOrThrow()));
|
|
2456
|
+
for (const object of objects) {
|
|
2457
|
+
if (!Node.isObjectLiteralExpression(object)) continue;
|
|
2458
|
+
const props = object.getProperties();
|
|
2459
|
+
const gone = props.map(p => properties.includes(p));
|
|
2460
|
+
if (gone.every(Boolean)) {
|
|
2461
|
+
edits.push({
|
|
2462
|
+
start: object.getStart() + 1,
|
|
2463
|
+
end: object.getEnd() - 1,
|
|
2464
|
+
text: ''
|
|
2465
|
+
});
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
const lastKept = gone.lastIndexOf(false);
|
|
2469
|
+
props.forEach((p, i) => {
|
|
2470
|
+
if (gone[i] && i < lastKept) {
|
|
2471
|
+
edits.push({
|
|
2472
|
+
start: p.getFullStart(),
|
|
2473
|
+
end: props[i + 1].getFullStart(),
|
|
2474
|
+
text: ''
|
|
2475
|
+
});
|
|
2476
|
+
}
|
|
2477
|
+
});
|
|
2478
|
+
if (lastKept < props.length - 1) {
|
|
2479
|
+
edits.push({
|
|
2480
|
+
start: props[lastKept].getEnd(),
|
|
2481
|
+
end: props.at(-1).getEnd(),
|
|
2482
|
+
text: ''
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
return edits;
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
// ---- F: fromPromise → createAsyncLogic ---------------------------------------
|
|
2490
|
+
|
|
2491
|
+
function promiseFunction(arg) {
|
|
2492
|
+
const value = unwrap(arg);
|
|
2493
|
+
if (isFunctionLike(value)) return value;
|
|
2494
|
+
if (Node.isIdentifier(value)) {
|
|
2495
|
+
const init = resolveConstInitializer(value);
|
|
2496
|
+
if (init && isFunctionLike(init)) return init;
|
|
2497
|
+
const decl = value.getSymbol()?.getDeclarations().find(Node.isFunctionDeclaration);
|
|
2498
|
+
return decl;
|
|
2499
|
+
}
|
|
2500
|
+
return undefined;
|
|
2501
|
+
}
|
|
2502
|
+
function usesSystemOrEmit(fn) {
|
|
2503
|
+
if (!Node.isArrowFunction(fn) && !Node.isFunctionExpression(fn) && !Node.isFunctionDeclaration(fn)) return true;
|
|
2504
|
+
const [param, ...rest] = fn.getParameters();
|
|
2505
|
+
if (rest.length) return true;
|
|
2506
|
+
if (!param) return false;
|
|
2507
|
+
const name = param.getNameNode();
|
|
2508
|
+
if (Node.isObjectBindingPattern(name)) {
|
|
2509
|
+
return name.getElements().some(element => {
|
|
2510
|
+
const key = (element.getPropertyNameNode() ?? element.getNameNode()).getText();
|
|
2511
|
+
return !!element.getDotDotDotToken() || key === 'system' || key === 'emit';
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
return /\b(system|emit)\b/.test(fn.getText());
|
|
2515
|
+
}
|
|
2516
|
+
function lowerFromPromise(sourceFile, notes) {
|
|
2517
|
+
const find = () => {
|
|
2518
|
+
const xs = getXstateImports(sourceFile);
|
|
2519
|
+
return sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).filter(call => {
|
|
2520
|
+
if (xstateCallee(call, xs) !== 'fromPromise') return false;
|
|
2521
|
+
const args = call.getArguments();
|
|
2522
|
+
const fn = args.length === 1 && promiseFunction(args[0]);
|
|
2523
|
+
if (!fn || usesSystemOrEmit(fn)) {
|
|
2524
|
+
notes.push(`${where(sourceFile, call)} — not lowered: fromPromise logic reads \`system\` or \`emit\`, or is not a local function`);
|
|
2525
|
+
return false;
|
|
2526
|
+
}
|
|
2527
|
+
return true;
|
|
2528
|
+
});
|
|
2529
|
+
};
|
|
2530
|
+
if (!find().length) return false;
|
|
2531
|
+
const local = ensureNamedImport(sourceFile, 'createAsyncLogic');
|
|
2532
|
+
if (!local) return false;
|
|
2533
|
+
const calls = find().reverse();
|
|
2534
|
+
for (const call of calls) {
|
|
2535
|
+
call.replaceWithText(`${local}({ run: ${call.getArguments()[0].getText()} })`);
|
|
2536
|
+
}
|
|
2537
|
+
return true;
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// ---- G: imports -----------------------------------------------------------------
|
|
2541
|
+
|
|
2542
|
+
function cleanupImports(sourceFile) {
|
|
2543
|
+
const references = new Map();
|
|
2544
|
+
for (const id of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
2545
|
+
if (id.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
|
|
2546
|
+
const parent = id.getParent();
|
|
2547
|
+
if ((Node.isPropertyAssignment(parent) || Node.isPropertyAccessExpression(parent) || Node.isMethodDeclaration(parent) || Node.isPropertySignature(parent)) && parent.getNameNode() === id) continue;
|
|
2548
|
+
references.set(id.getText(), (references.get(id.getText()) ?? 0) + 1);
|
|
2549
|
+
}
|
|
2550
|
+
for (const imp of sourceFile.getImportDeclarations()) {
|
|
2551
|
+
if (imp.getModuleSpecifierValue() !== 'xstate') continue;
|
|
2552
|
+
for (const spec of imp.getNamedImports()) {
|
|
2553
|
+
const local = (spec.getAliasNode() ?? spec.getNameNode()).getText();
|
|
2554
|
+
if (REMOVABLE_IMPORTS.has(spec.getName()) && !references.get(local)) {
|
|
2555
|
+
spec.remove();
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
if (!imp.getNamedImports().length && !imp.getDefaultImport() && !imp.getNamespaceImport()) {
|
|
2559
|
+
imp.remove();
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* Lowers v5 action creators, guards and transition objects to v6 transition
|
|
2566
|
+
* and entry/exit functions. Anything it cannot lower is left untouched for the
|
|
2567
|
+
* report transforms.
|
|
2568
|
+
*/
|
|
2569
|
+
const lowerActions = {
|
|
2570
|
+
name: 'lower-actions',
|
|
2571
|
+
description: 'Rewrite v5 transition `actions`/`guard`, `entry`/`exit` lists, action creators, guard combinators, named `assign` actions, the second `createMachine` argument and `fromPromise` as v6 functions.',
|
|
2572
|
+
apply(sourceFile) {
|
|
2573
|
+
const before = sourceFile.getFullText();
|
|
2574
|
+
const notes = [];
|
|
2575
|
+
let changed = moveImplementations(sourceFile, notes);
|
|
2576
|
+
changed = lowerMachines(sourceFile, notes) || changed;
|
|
2577
|
+
changed = lowerFromPromise(sourceFile, notes) || changed;
|
|
2578
|
+
if (sourceFile.getFullText().includes(MATCHES_STATE)) {
|
|
2579
|
+
const local = ensureNamedImport(sourceFile, 'matchesState') ?? 'matchesState';
|
|
2580
|
+
sourceFile.replaceWithText(sourceFile.getFullText().split(MATCHES_STATE).join(local));
|
|
2581
|
+
}
|
|
2582
|
+
if (changed) cleanupImports(sourceFile);
|
|
2583
|
+
return {
|
|
2584
|
+
changed: sourceFile.getFullText() !== before,
|
|
2585
|
+
notes
|
|
2586
|
+
};
|
|
2587
|
+
}
|
|
2588
|
+
};
|
|
2589
|
+
|
|
2590
|
+
/**
|
|
2591
|
+
* v5 `'xstate'` exports that require structural rewrites in v6 and are too
|
|
2592
|
+
* risky to automate. Value is a one-line hint for the v6 replacement.
|
|
2593
|
+
*/
|
|
2594
|
+
const REMOVED_APIS = {
|
|
2595
|
+
assign: 'return a `{ context }` patch from the inline transition function. A named `assign` in `setup({ actions })` becomes a plain function returning the patch; call it from the transition and return its result (`enq(actions.x)` ignores return values).',
|
|
2596
|
+
raise: 'use `enq.raise(...)` inside an action',
|
|
2597
|
+
sendTo: 'use `enq.sendTo(...)` inside an action',
|
|
2598
|
+
sendParent: 'use `enq.sendTo(...)` with the parent ref inside an action',
|
|
2599
|
+
forwardTo: 'use `enq.sendTo(target, event)` inside an action',
|
|
2600
|
+
emit: 'use `enq.emit(...)` inside an action',
|
|
2601
|
+
log: 'use `enq.log(...)` inside an action',
|
|
2602
|
+
cancel: 'use `enq.cancel(...)` inside an action',
|
|
2603
|
+
spawnChild: 'use `enq.spawn(...)` inside an action',
|
|
2604
|
+
stop: 'use `enq.stop(...)` inside an action',
|
|
2605
|
+
stopChild: 'use `enq.stop(...)` inside an action',
|
|
2606
|
+
enqueueActions: 'action functions now receive an `enq` enqueue object directly',
|
|
2607
|
+
and: 'inline the guard as `(...) => a(...) && b(...)`',
|
|
2608
|
+
or: 'inline the guard as `(...) => a(...) || b(...)`',
|
|
2609
|
+
not: 'inline the guard as `(...) => !a(...)`',
|
|
2610
|
+
stateIn: 'use `self.getSnapshot().matches(...)`',
|
|
2611
|
+
fromPromise: 'use `createAsyncLogic(...)`',
|
|
2612
|
+
fromTransition: 'use `createStepLogic({ context, run })`',
|
|
2613
|
+
toObserver: 'pass an observer object or callback directly',
|
|
2614
|
+
pathToStateValue: 'build the state value object directly',
|
|
2615
|
+
SpecialTargets: 'use `enq.sendTo(self, event)` for internal sends; parent sends use `enq.sendTo(...)` with the parent ref'
|
|
2616
|
+
};
|
|
2617
|
+
|
|
2618
|
+
/**
|
|
2619
|
+
* Detects (does NOT transform) usages of v5 `'xstate'` APIs that were removed
|
|
2620
|
+
* or restructured in v6. Reports file:line + API + a replacement hint via the
|
|
2621
|
+
* returned notes.
|
|
2622
|
+
*/
|
|
2623
|
+
const reportRemovedApis = {
|
|
2624
|
+
name: 'report-removed-apis',
|
|
2625
|
+
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.',
|
|
2626
|
+
apply(sourceFile) {
|
|
2627
|
+
const notes = [];
|
|
2628
|
+
const sites = [];
|
|
2629
|
+
const xstateImports = sourceFile.getImportDeclarations().filter(imp => imp.getModuleSpecifierValue() === 'xstate');
|
|
2630
|
+
|
|
2631
|
+
// Map local binding name → canonical API name (respecting aliases).
|
|
2632
|
+
const tracked = new Map();
|
|
2633
|
+
for (const imp of xstateImports) {
|
|
2634
|
+
for (const spec of imp.getNamedImports()) {
|
|
2635
|
+
const original = spec.getNameNode().getText();
|
|
2636
|
+
if (REMOVED_APIS[original]) {
|
|
2637
|
+
const local = (spec.getAliasNode() ?? spec.getNameNode()).getText();
|
|
2638
|
+
tracked.set(local, original);
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
if (tracked.size === 0) {
|
|
2643
|
+
return {
|
|
2644
|
+
changed: false,
|
|
2645
|
+
notes,
|
|
2646
|
+
sites
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
const filePath = sourceFile.getFilePath();
|
|
2650
|
+
for (const ident of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
|
|
2651
|
+
const local = ident.getText();
|
|
2652
|
+
const api = tracked.get(local);
|
|
2653
|
+
if (!api) {
|
|
2654
|
+
continue;
|
|
2655
|
+
}
|
|
2656
|
+
// Skip member names such as `enq.emit`; they are not the import.
|
|
2657
|
+
const parent = ident.getParent();
|
|
2658
|
+
if ((Node.isPropertyAccessExpression(parent) || Node.isPropertyAssignment(parent)) && parent.getNameNode() === ident) {
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
// Skip the import specifier declarations themselves.
|
|
2662
|
+
const parentKind = parent?.getKind();
|
|
2663
|
+
if (parentKind === SyntaxKind.ImportSpecifier || parentKind === SyntaxKind.ImportClause) {
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
const {
|
|
2667
|
+
line,
|
|
2668
|
+
column
|
|
2669
|
+
} = sourceFile.getLineAndColumnAtPos(ident.getPos());
|
|
2670
|
+
const hint = REMOVED_APIS[api];
|
|
2671
|
+
notes.push(`${filePath}:${line}:${column} — '${api}' → ${hint}`);
|
|
2672
|
+
sites.push({
|
|
2673
|
+
file: filePath,
|
|
2674
|
+
line,
|
|
2675
|
+
column,
|
|
2676
|
+
api,
|
|
2677
|
+
hint
|
|
2678
|
+
});
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
// Never mutates the file.
|
|
2682
|
+
return {
|
|
2683
|
+
changed: false,
|
|
2684
|
+
notes,
|
|
2685
|
+
sites
|
|
2686
|
+
};
|
|
2687
|
+
}
|
|
2688
|
+
};
|
|
2689
|
+
|
|
2690
|
+
/** Keys whose string values (or string array elements) are state targets. */
|
|
2691
|
+
const TARGET_KEYS = new Set(['target', 'initial', 'on', 'after', 'always', 'onDone', 'onError']);
|
|
2692
|
+
|
|
2693
|
+
/** Calls whose first argument is a state value or `#id` reference. */
|
|
2694
|
+
const MATCH_CALLS = new Set(['matches', 'stateIn']);
|
|
2695
|
+
function getPropertyName(node) {
|
|
2696
|
+
if (Node.isPropertyAssignment(node)) {
|
|
2697
|
+
const name = node.getNameNode();
|
|
2698
|
+
if (Node.isIdentifier(name) || Node.isStringLiteral(name)) {
|
|
2699
|
+
return Node.isStringLiteral(name) ? name.getLiteralText() : name.getText();
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
2702
|
+
return undefined;
|
|
2703
|
+
}
|
|
2704
|
+
function getStringProperty(object, name) {
|
|
2705
|
+
const property = object.getProperty(name);
|
|
2706
|
+
if (!property || !Node.isPropertyAssignment(property)) return undefined;
|
|
2707
|
+
const initializer = property.getInitializer();
|
|
2708
|
+
return initializer && (Node.isStringLiteral(initializer) || Node.isNoSubstitutionTemplateLiteral(initializer)) ? initializer.getLiteralText() : undefined;
|
|
2709
|
+
}
|
|
2710
|
+
const ID_PATH_HINT = "'#…' 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)";
|
|
2711
|
+
/**
|
|
2712
|
+
* Collects the state ids that one machine config literal declares: explicit
|
|
2713
|
+
* state `id`s and, for `createMachine` configs, implicit ids (the path from
|
|
2714
|
+
* the root, prefixed by the machine id when one is set). Invoke and actor ids
|
|
2715
|
+
* are not state ids and are not collected.
|
|
2716
|
+
*/
|
|
2717
|
+
function collectStateIds(config, withImplicitIds) {
|
|
2718
|
+
const ids = new Set();
|
|
2719
|
+
function walk(stateConfig, implicitId) {
|
|
2720
|
+
const explicitId = getStringProperty(stateConfig, 'id');
|
|
2721
|
+
if (explicitId !== undefined) ids.add(explicitId);else if (withImplicitIds) ids.add(implicitId);
|
|
2722
|
+
const states = stateConfig.getProperty('states');
|
|
2723
|
+
if (!states || !Node.isPropertyAssignment(states)) return;
|
|
2724
|
+
const statesObject = states.getInitializer();
|
|
2725
|
+
if (!statesObject || !Node.isObjectLiteralExpression(statesObject)) return;
|
|
2726
|
+
for (const state of statesObject.getProperties()) {
|
|
2727
|
+
const key = getPropertyName(state);
|
|
2728
|
+
if (key === undefined || !Node.isPropertyAssignment(state)) continue;
|
|
2729
|
+
const child = state.getInitializer();
|
|
2730
|
+
if (!child || !Node.isObjectLiteralExpression(child)) continue;
|
|
2731
|
+
walk(child, implicitId ? `${implicitId}.${key}` : key);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
walk(config, getStringProperty(config, 'id') ?? '');
|
|
2735
|
+
return ids;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2738
|
+
/**
|
|
2739
|
+
* Finds the `createMachine(...)` or `createStateConfig(...)` config literal
|
|
2740
|
+
* that contains `node`, if any.
|
|
2741
|
+
*/
|
|
2742
|
+
function findEnclosingConfig(node, machineConfigs, stateConfigs) {
|
|
2743
|
+
for (const ancestor of node.getAncestors()) {
|
|
2744
|
+
if (!Node.isObjectLiteralExpression(ancestor)) continue;
|
|
2745
|
+
if (machineConfigs.has(ancestor)) {
|
|
2746
|
+
return {
|
|
2747
|
+
config: ancestor,
|
|
2748
|
+
withImplicitIds: true
|
|
2749
|
+
};
|
|
2750
|
+
}
|
|
2751
|
+
if (stateConfigs.has(ancestor)) {
|
|
2752
|
+
return {
|
|
2753
|
+
config: ancestor,
|
|
2754
|
+
withImplicitIds: false
|
|
2755
|
+
};
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
return undefined;
|
|
2759
|
+
}
|
|
2760
|
+
function isMatchArgument(literal) {
|
|
2761
|
+
const call = literal.getParent();
|
|
2762
|
+
if (!call || !Node.isCallExpression(call)) return false;
|
|
2763
|
+
if (call.getArguments()[0] !== literal) return false;
|
|
2764
|
+
const expression = call.getExpression();
|
|
2765
|
+
const name = Node.isIdentifier(expression) ? expression.getText() : Node.isPropertyAccessExpression(expression) ? expression.getName() : undefined;
|
|
2766
|
+
return !!name && MATCH_CALLS.has(name);
|
|
2767
|
+
}
|
|
2768
|
+
function isTargetValue(literal) {
|
|
2769
|
+
let node = literal;
|
|
2770
|
+
let parent = node.getParent();
|
|
2771
|
+
if (parent && Node.isArrayLiteralExpression(parent)) {
|
|
2772
|
+
node = parent;
|
|
2773
|
+
parent = node.getParent();
|
|
2774
|
+
}
|
|
2775
|
+
if (!parent || !Node.isPropertyAssignment(parent)) return false;
|
|
2776
|
+
const name = getPropertyName(parent);
|
|
2777
|
+
if (name && TARGET_KEYS.has(name)) return true;
|
|
2778
|
+
// A bare string transition inside `on: { EVENT: '#a.b' }` or
|
|
2779
|
+
// `after: { 1000: '#a.b' }`.
|
|
2780
|
+
const container = parent.getParent();
|
|
2781
|
+
const owner = container?.getParent();
|
|
2782
|
+
const ownerName = owner ? getPropertyName(owner) : undefined;
|
|
2783
|
+
return ownerName === 'on' || ownerName === 'after';
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
/**
|
|
2787
|
+
* Reports (does NOT transform) `#a.b` targets and `matches('#a.b')`/
|
|
2788
|
+
* `stateIn('#a.b')` references that the codemod cannot verify as whole state
|
|
2789
|
+
* ids. In v6, the text after `#` is an exact id and is never split into an
|
|
2790
|
+
* id followed by a path.
|
|
2791
|
+
*/
|
|
2792
|
+
const reportIdPathTargets = {
|
|
2793
|
+
name: 'report-id-path-targets',
|
|
2794
|
+
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.",
|
|
2795
|
+
apply(sourceFile) {
|
|
2796
|
+
const notes = [];
|
|
2797
|
+
const sites = [];
|
|
2798
|
+
const filePath = sourceFile.getFilePath();
|
|
2799
|
+
const machineConfigs = new Set(findMachineConfigObjects(sourceFile, ['createMachine']));
|
|
2800
|
+
const stateConfigs = new Set(findMachineConfigObjects(sourceFile, ['createStateConfig']));
|
|
2801
|
+
const idsByConfig = new Map();
|
|
2802
|
+
const literals = [...sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral), ...sourceFile.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)].sort((a, b) => a.getStart() - b.getStart());
|
|
635
2803
|
for (const literal of literals) {
|
|
636
2804
|
const text = literal.getLiteralText();
|
|
637
2805
|
if (!text.startsWith('#') || !text.includes('.')) continue;
|
|
@@ -651,23 +2819,458 @@ const reportIdPathTargets = {
|
|
|
651
2819
|
line,
|
|
652
2820
|
column
|
|
653
2821
|
} = sourceFile.getLineAndColumnAtPos(literal.getStart());
|
|
654
|
-
notes.push(`${filePath}:${line}:${column} — '${text}' →
|
|
2822
|
+
notes.push(`${filePath}:${line}:${column} — '${text}' → ${ID_PATH_HINT}`);
|
|
2823
|
+
sites.push({
|
|
2824
|
+
file: filePath,
|
|
2825
|
+
line,
|
|
2826
|
+
column,
|
|
2827
|
+
api: text,
|
|
2828
|
+
hint: ID_PATH_HINT
|
|
2829
|
+
});
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// Never mutates the file.
|
|
2833
|
+
return {
|
|
2834
|
+
changed: false,
|
|
2835
|
+
notes,
|
|
2836
|
+
sites
|
|
2837
|
+
};
|
|
2838
|
+
}
|
|
2839
|
+
};
|
|
2840
|
+
|
|
2841
|
+
const SUFFIXED_LIFECYCLE = /^xstate\.(done\.state|done\.actor|error\.actor|snapshot|after)\.(.+)$/;
|
|
2842
|
+
// v6 category type and the payload field that carries the v5 suffix.
|
|
2843
|
+
const REWRITES = {
|
|
2844
|
+
'done.actor': {
|
|
2845
|
+
type: 'xstate.done.actor',
|
|
2846
|
+
field: 'actorId'
|
|
2847
|
+
},
|
|
2848
|
+
'error.actor': {
|
|
2849
|
+
type: 'xstate.error.actor',
|
|
2850
|
+
field: 'actorId'
|
|
2851
|
+
},
|
|
2852
|
+
snapshot: {
|
|
2853
|
+
type: 'xstate.snapshot.actor',
|
|
2854
|
+
field: 'actorId'
|
|
2855
|
+
},
|
|
2856
|
+
'done.state': {
|
|
2857
|
+
type: 'xstate.done.state',
|
|
2858
|
+
field: 'stateId'
|
|
2859
|
+
}
|
|
2860
|
+
};
|
|
2861
|
+
const AFTER_HINT = 'v6 raises `xstate.after` with `delay` and `stateId`; suffixed keys never match. Use `after: { <delay>: ... }` on the state.';
|
|
2862
|
+
function keyHint(category, suffix, reason) {
|
|
2863
|
+
const rewrite = REWRITES[category];
|
|
2864
|
+
if (!rewrite) return AFTER_HINT;
|
|
2865
|
+
return `${reason} Key on '${rewrite.type}' and select the payload with \`matches: { ${rewrite.field}: '${suffix}' }\`.`;
|
|
2866
|
+
}
|
|
2867
|
+
const LITERAL_HINT = 'v6 lifecycle event types carry the id in a payload field (`actorId`, `stateId`, `delay`), so this string never equals `event.type`. Compare the category type and the payload field instead.';
|
|
2868
|
+
|
|
2869
|
+
/** Parses a suffixed lifecycle type; wildcards and `xstate.snapshot.actor` are v6-valid. */
|
|
2870
|
+
function parseSuffixed(text) {
|
|
2871
|
+
const match = SUFFIXED_LIFECYCLE.exec(text);
|
|
2872
|
+
if (!match) return undefined;
|
|
2873
|
+
const [, category, suffix] = match;
|
|
2874
|
+
if (suffix === '*' || suffix.endsWith('.*')) return undefined;
|
|
2875
|
+
if (category === 'snapshot' && suffix === 'actor') return undefined;
|
|
2876
|
+
return {
|
|
2877
|
+
category,
|
|
2878
|
+
suffix
|
|
2879
|
+
};
|
|
2880
|
+
}
|
|
2881
|
+
function getKeyText$1(node) {
|
|
2882
|
+
if (!Node.isPropertyAssignment(node)) return undefined;
|
|
2883
|
+
const name = node.getNameNode();
|
|
2884
|
+
if (Node.isStringLiteral(name)) return name.getLiteralText();
|
|
2885
|
+
if (Node.isIdentifier(name)) return name.getText();
|
|
2886
|
+
return undefined;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
/** v5 `done.state` suffixes are state ids, which used the `(machine)` placeholder. */
|
|
2890
|
+
function v6StateId(suffix) {
|
|
2891
|
+
return suffix.startsWith('(machine).') ? suffix.slice('(machine).'.length) : suffix;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/**
|
|
2895
|
+
* Builds the v6 transition for a suffixed key, or explains why it cannot.
|
|
2896
|
+
* Object literals get `matches` prepended; functions move under `to`.
|
|
2897
|
+
*/
|
|
2898
|
+
function rewriteInitializer(entry, field, value) {
|
|
2899
|
+
const init = entry.getInitializerOrThrow();
|
|
2900
|
+
const matches = `matches: { ${field}: ${JSON.stringify(value).replace(/"/g, "'")} }`;
|
|
2901
|
+
if (Node.isObjectLiteralExpression(init)) {
|
|
2902
|
+
if (init.getProperty('matches')) return '';
|
|
2903
|
+
const props = init.getProperties().map(p => p.getText());
|
|
2904
|
+
return `{ ${[matches, ...props].join(', ')} }`;
|
|
2905
|
+
}
|
|
2906
|
+
if (Node.isArrowFunction(init) || Node.isFunctionExpression(init)) {
|
|
2907
|
+
return `{ ${matches}, to: ${init.getText()} }`;
|
|
2908
|
+
}
|
|
2909
|
+
return '';
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
/**
|
|
2913
|
+
* Rewrites `on` keys that use v5 suffixed lifecycle event types, such as
|
|
2914
|
+
* `'xstate.done.actor.job'`, to the v6 category type with `matches`.
|
|
2915
|
+
* Reports what it cannot rewrite: `xstate.after.<delay>.<id>` keys, keys
|
|
2916
|
+
* whose category type is already present or used twice in the same `on`,
|
|
2917
|
+
* and suffixed type strings outside `on` keys (for example
|
|
2918
|
+
* `event.type === 'xstate.done.actor.job'`).
|
|
2919
|
+
*/
|
|
2920
|
+
const suffixedLifecycleEvents = {
|
|
2921
|
+
name: 'suffixed-lifecycle-events',
|
|
2922
|
+
description: "Rewrite `on` keys such as 'xstate.done.actor.<id>' to the v6 category type with `matches`; report suffixed types it cannot rewrite.",
|
|
2923
|
+
apply(sourceFile) {
|
|
2924
|
+
const notes = [];
|
|
2925
|
+
const sites = [];
|
|
2926
|
+
const filePath = sourceFile.getFilePath();
|
|
2927
|
+
const handled = new Set();
|
|
2928
|
+
let changed = false;
|
|
2929
|
+
const report = (node, api, hint) => {
|
|
2930
|
+
const {
|
|
2931
|
+
line,
|
|
2932
|
+
column
|
|
2933
|
+
} = sourceFile.getLineAndColumnAtPos(node.getStart());
|
|
2934
|
+
notes.push(`${filePath}:${line}:${column} — '${api}' → ${hint}`);
|
|
2935
|
+
sites.push({
|
|
2936
|
+
file: filePath,
|
|
2937
|
+
line,
|
|
2938
|
+
column,
|
|
2939
|
+
api,
|
|
2940
|
+
hint
|
|
2941
|
+
});
|
|
2942
|
+
};
|
|
2943
|
+
const onObjects = [];
|
|
2944
|
+
for (const config of findMachineConfigObjects(sourceFile)) {
|
|
2945
|
+
for (const property of config.getDescendantsOfKind(SyntaxKind.PropertyAssignment)) {
|
|
2946
|
+
if (getKeyText$1(property) !== 'on') continue;
|
|
2947
|
+
const on = property.getInitializer();
|
|
2948
|
+
if (on && Node.isObjectLiteralExpression(on) && !onObjects.includes(on)) {
|
|
2949
|
+
onObjects.push(on);
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
for (const on of onObjects) {
|
|
2954
|
+
const entries = on.getProperties().flatMap(entry => {
|
|
2955
|
+
const key = getKeyText$1(entry);
|
|
2956
|
+
const parsed = key ? parseSuffixed(key) : undefined;
|
|
2957
|
+
return parsed && Node.isPropertyAssignment(entry) ? [{
|
|
2958
|
+
entry,
|
|
2959
|
+
key: key,
|
|
2960
|
+
...parsed
|
|
2961
|
+
}] : [];
|
|
2962
|
+
});
|
|
2963
|
+
const existingKeys = new Set(on.getProperties().map(getKeyText$1).filter(Boolean));
|
|
2964
|
+
for (const item of entries) {
|
|
2965
|
+
handled.add(item.entry.getNameNode());
|
|
2966
|
+
const rewrite = REWRITES[item.category];
|
|
2967
|
+
if (!rewrite) {
|
|
2968
|
+
report(item.entry, item.key, AFTER_HINT);
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
const sameCategory = entries.filter(other => REWRITES[other.category]?.type === rewrite.type);
|
|
2972
|
+
if (existingKeys.has(rewrite.type) || sameCategory.length > 1) {
|
|
2973
|
+
report(item.entry, item.key, keyHint(item.category, item.suffix, `\`on\` already handles '${rewrite.type}' or several suffixed keys share it; merge them into one transition by hand.`));
|
|
2974
|
+
continue;
|
|
2975
|
+
}
|
|
2976
|
+
const value = item.category === 'done.state' ? v6StateId(item.suffix) : item.suffix;
|
|
2977
|
+
const replacement = rewriteInitializer(item.entry, rewrite.field, value);
|
|
2978
|
+
if (!replacement) {
|
|
2979
|
+
report(item.entry, item.key, keyHint(item.category, item.suffix, 'The transition is not an object or function literal, or already has `matches`.'));
|
|
2980
|
+
continue;
|
|
2981
|
+
}
|
|
2982
|
+
item.entry.replaceWithText(`'${rewrite.type}': ${replacement}`);
|
|
2983
|
+
changed = true;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
const importsXState = sourceFile.getImportDeclarations().some(d => /^xstate(\/|$)/.test(d.getModuleSpecifierValue()));
|
|
2987
|
+
if (importsXState) {
|
|
2988
|
+
for (const literal of [...sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral), ...sourceFile.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)]) {
|
|
2989
|
+
if (literal.wasForgotten() || handled.has(literal)) continue;
|
|
2990
|
+
if (Node.isImportDeclaration(literal.getParent())) continue;
|
|
2991
|
+
const text = literal.getLiteralText();
|
|
2992
|
+
if (parseSuffixed(text)) report(literal, text, LITERAL_HINT);
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
return {
|
|
2996
|
+
changed,
|
|
2997
|
+
notes,
|
|
2998
|
+
sites
|
|
2999
|
+
};
|
|
3000
|
+
}
|
|
3001
|
+
};
|
|
3002
|
+
|
|
3003
|
+
const ENTRY_EXIT_HINT = 'v6 `entry`/`exit` is a single function `(args, enq) => { ... }`; call named actions with `enq(actions.name, params)`.';
|
|
3004
|
+
const TRANSITION_ACTIONS_HINT = 'transition `actions` was removed; use an inline transition function `(args, enq) => { ... }` and queue effects on `enq`; call named actions with `enq(actions.name, params)`.';
|
|
3005
|
+
const TRANSITION_GUARD_HINT = 'transition `guard` was removed; return `{ target }` from an inline transition function when the condition holds, or `undefined` to reject the event; named guards are plain predicates called as `guards.name(params)`.';
|
|
3006
|
+
const TRANSITION_ARRAY_HINT = 'v6 takes one transition function; combine the candidates into an if-chain: `(args, enq) => { if (cond) return { target }; return { target: other }; }`.';
|
|
3007
|
+
const INDIRECT_CONFIG_HINT = 'v6 rejects v5 transition objects, `actions`/`guard` and action lists; this value is not an inline literal or an in-file `const`, so the codemod cannot see into it. Inline it or migrate it by hand.';
|
|
3008
|
+
function getKeyText(node) {
|
|
3009
|
+
if (!Node.isPropertyAssignment(node)) return undefined;
|
|
3010
|
+
const name = node.getNameNode();
|
|
3011
|
+
if (Node.isStringLiteral(name)) return name.getLiteralText();
|
|
3012
|
+
if (Node.isIdentifier(name)) return name.getText();
|
|
3013
|
+
return undefined;
|
|
3014
|
+
}
|
|
3015
|
+
function getProperty(object, key) {
|
|
3016
|
+
if (!Node.isObjectLiteralExpression(object)) return undefined;
|
|
3017
|
+
const property = object.getProperties().find(candidate => getKeyText(candidate) === key);
|
|
3018
|
+
return Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/**
|
|
3022
|
+
* Reports (does NOT transform) v5 action shapes that v6 rejects: non-function
|
|
3023
|
+
* `entry` / `exit` values, transition arrays, and `actions`, `guard` or `cond`
|
|
3024
|
+
* on transition objects.
|
|
3025
|
+
*/
|
|
3026
|
+
const reportActionShapes = {
|
|
3027
|
+
name: 'report-action-shapes',
|
|
3028
|
+
description: 'Detect (report only) non-function `entry`/`exit` values, transition arrays and transition `actions`/`guard`/`cond`, which v6 rejects.',
|
|
3029
|
+
apply(sourceFile) {
|
|
3030
|
+
const notes = [];
|
|
3031
|
+
const sites = [];
|
|
3032
|
+
const filePath = sourceFile.getFilePath();
|
|
3033
|
+
const seen = new Set();
|
|
3034
|
+
const report = (node, api, hint) => {
|
|
3035
|
+
if (seen.has(node)) return;
|
|
3036
|
+
seen.add(node);
|
|
3037
|
+
const {
|
|
3038
|
+
line,
|
|
3039
|
+
column
|
|
3040
|
+
} = sourceFile.getLineAndColumnAtPos(node.getStart());
|
|
3041
|
+
notes.push(`${filePath}:${line}:${column} — '${api}' → ${hint}`);
|
|
3042
|
+
sites.push({
|
|
3043
|
+
file: filePath,
|
|
3044
|
+
line,
|
|
3045
|
+
column,
|
|
3046
|
+
api,
|
|
3047
|
+
hint
|
|
3048
|
+
});
|
|
3049
|
+
};
|
|
3050
|
+
|
|
3051
|
+
/** The literal behind a reference, or undefined (reported) when unknown. */
|
|
3052
|
+
const look = node => {
|
|
3053
|
+
if (!node || !isIndirect(node)) return node;
|
|
3054
|
+
const target = derefConfig(node);
|
|
3055
|
+
if (!target) {
|
|
3056
|
+
report(node, 'indirect-config', INDIRECT_CONFIG_HINT);
|
|
3057
|
+
return undefined;
|
|
3058
|
+
}
|
|
3059
|
+
return target.node;
|
|
3060
|
+
};
|
|
3061
|
+
const visitTransition = node => {
|
|
3062
|
+
const value = look(node);
|
|
3063
|
+
if (!value) return;
|
|
3064
|
+
if (Node.isArrayLiteralExpression(value)) {
|
|
3065
|
+
report(value, 'transition-array', TRANSITION_ARRAY_HINT);
|
|
3066
|
+
value.getElements().forEach(visitTransition);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
if (!Node.isObjectLiteralExpression(value)) return;
|
|
3070
|
+
for (const property of value.getProperties()) {
|
|
3071
|
+
const key = getKeyText(property);
|
|
3072
|
+
if (key === 'actions') {
|
|
3073
|
+
report(property, 'actions', TRANSITION_ACTIONS_HINT);
|
|
3074
|
+
} else if (key === 'guard' || key === 'cond') {
|
|
3075
|
+
report(property, key, TRANSITION_GUARD_HINT);
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
};
|
|
3079
|
+
const visitTransitionMap = node => {
|
|
3080
|
+
const value = look(node);
|
|
3081
|
+
if (!value || !Node.isObjectLiteralExpression(value)) return;
|
|
3082
|
+
for (const property of value.getProperties()) {
|
|
3083
|
+
if (Node.isPropertyAssignment(property)) {
|
|
3084
|
+
visitTransition(property.getInitializer());
|
|
3085
|
+
} else if (Node.isSpreadAssignment(property)) {
|
|
3086
|
+
visitTransitionMap(property);
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
};
|
|
3090
|
+
const visitStates = node => {
|
|
3091
|
+
const value = look(node);
|
|
3092
|
+
if (!value || !Node.isObjectLiteralExpression(value)) return;
|
|
3093
|
+
for (const child of value.getProperties()) {
|
|
3094
|
+
if (Node.isPropertyAssignment(child)) {
|
|
3095
|
+
visitState(child.getInitializer());
|
|
3096
|
+
} else if (Node.isShorthandPropertyAssignment(child)) {
|
|
3097
|
+
visitState(child);
|
|
3098
|
+
} else if (Node.isSpreadAssignment(child)) {
|
|
3099
|
+
visitStates(child);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
};
|
|
3103
|
+
const visitState = node => {
|
|
3104
|
+
const state = look(node);
|
|
3105
|
+
if (!state || !Node.isObjectLiteralExpression(state)) return;
|
|
3106
|
+
for (const property of state.getProperties()) {
|
|
3107
|
+
if (Node.isSpreadAssignment(property)) {
|
|
3108
|
+
visitState(property);
|
|
3109
|
+
continue;
|
|
3110
|
+
}
|
|
3111
|
+
const key = getKeyText(property);
|
|
3112
|
+
if (!key || !Node.isPropertyAssignment(property)) continue;
|
|
3113
|
+
const raw = property.getInitializer();
|
|
3114
|
+
const value = raw && ['entry', 'exit', 'invoke'].includes(key) ? look(raw) : raw;
|
|
3115
|
+
switch (key) {
|
|
3116
|
+
case 'entry':
|
|
3117
|
+
case 'exit':
|
|
3118
|
+
if (value && !Node.isArrowFunction(value) && !Node.isFunctionExpression(value) && !Node.isFunctionDeclaration(value)) {
|
|
3119
|
+
report(property, key, ENTRY_EXIT_HINT);
|
|
3120
|
+
}
|
|
3121
|
+
break;
|
|
3122
|
+
case 'on':
|
|
3123
|
+
case 'after':
|
|
3124
|
+
visitTransitionMap(value);
|
|
3125
|
+
break;
|
|
3126
|
+
case 'always':
|
|
3127
|
+
case 'onDone':
|
|
3128
|
+
case 'onError':
|
|
3129
|
+
case 'onSnapshot':
|
|
3130
|
+
visitTransition(value);
|
|
3131
|
+
break;
|
|
3132
|
+
case 'initial':
|
|
3133
|
+
if (value && Node.isObjectLiteralExpression(value)) {
|
|
3134
|
+
visitTransition(value);
|
|
3135
|
+
}
|
|
3136
|
+
break;
|
|
3137
|
+
case 'invoke':
|
|
3138
|
+
{
|
|
3139
|
+
const invokes = value && Node.isArrayLiteralExpression(value) ? value.getElements() : [value];
|
|
3140
|
+
for (const element of invokes) {
|
|
3141
|
+
const invoke = look(element);
|
|
3142
|
+
for (const event of ['onDone', 'onError', 'onSnapshot']) {
|
|
3143
|
+
visitTransition(invoke && getProperty(invoke, event));
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3148
|
+
case 'states':
|
|
3149
|
+
visitStates(value);
|
|
3150
|
+
break;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
};
|
|
3154
|
+
for (const config of findMachineConfigObjects(sourceFile)) {
|
|
3155
|
+
visitState(config);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// Never mutates the file.
|
|
3159
|
+
return {
|
|
3160
|
+
changed: false,
|
|
3161
|
+
notes,
|
|
3162
|
+
sites
|
|
3163
|
+
};
|
|
3164
|
+
}
|
|
3165
|
+
};
|
|
3166
|
+
|
|
3167
|
+
const SECOND_ARGUMENT_HINT = 'v6 `createMachine` takes only a config; the second argument is ignored. Move actions, guards, actors and delays into `setup({ ... }).createMachine(...)`.';
|
|
3168
|
+
function getCallName(call) {
|
|
3169
|
+
const expr = call.getExpression();
|
|
3170
|
+
if (Node.isIdentifier(expr)) return expr.getText();
|
|
3171
|
+
if (Node.isPropertyAccessExpression(expr)) return expr.getName();
|
|
3172
|
+
return undefined;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
/**
|
|
3176
|
+
* Reports (does NOT transform) a second `createMachine` argument, which v6
|
|
3177
|
+
* ignores. `lower-actions` moves object-literal ones into the config.
|
|
3178
|
+
*/
|
|
3179
|
+
const reportImplementations = {
|
|
3180
|
+
name: 'report-implementations',
|
|
3181
|
+
description: 'Detect (report only) a second `createMachine` argument, which v6 ignores.',
|
|
3182
|
+
apply(sourceFile) {
|
|
3183
|
+
const notes = [];
|
|
3184
|
+
const sites = [];
|
|
3185
|
+
const filePath = sourceFile.getFilePath();
|
|
3186
|
+
const report = (node, api, hint) => {
|
|
3187
|
+
const {
|
|
3188
|
+
line,
|
|
3189
|
+
column
|
|
3190
|
+
} = sourceFile.getLineAndColumnAtPos(node.getStart());
|
|
3191
|
+
notes.push(`${filePath}:${line}:${column} — '${api}' → ${hint}`);
|
|
3192
|
+
sites.push({
|
|
3193
|
+
file: filePath,
|
|
3194
|
+
line,
|
|
3195
|
+
column,
|
|
3196
|
+
api,
|
|
3197
|
+
hint
|
|
3198
|
+
});
|
|
3199
|
+
};
|
|
3200
|
+
for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
3201
|
+
const name = getCallName(call);
|
|
3202
|
+
const args = call.getArguments();
|
|
3203
|
+
if (name === 'createMachine' && args[1]) {
|
|
3204
|
+
report(args[1], 'implementations', SECOND_ARGUMENT_HINT);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Never mutates the file.
|
|
3209
|
+
return {
|
|
3210
|
+
changed: false,
|
|
3211
|
+
notes,
|
|
3212
|
+
sites
|
|
3213
|
+
};
|
|
3214
|
+
}
|
|
3215
|
+
};
|
|
3216
|
+
|
|
3217
|
+
const TRANSITION_HINT = 'v6 emits only `@xstate.actor` and `@xstate.transition`; read the event, snapshot, executed `actions` and `microsteps` from `@xstate.transition`.';
|
|
3218
|
+
const HINTS = {
|
|
3219
|
+
'@xstate.event': TRANSITION_HINT,
|
|
3220
|
+
'@xstate.snapshot': TRANSITION_HINT,
|
|
3221
|
+
'@xstate.action': TRANSITION_HINT,
|
|
3222
|
+
'@xstate.microstep': TRANSITION_HINT,
|
|
3223
|
+
'@xstate.deadletter': 'v6 has no dead-letter inspection event; observe undelivered events with the `onRejectedEvent` option of `createActor` or `system.onRejectedEvent(listener)`.'
|
|
3224
|
+
};
|
|
3225
|
+
|
|
3226
|
+
/**
|
|
3227
|
+
* Reports (does NOT transform) string literals naming v5 inspection event
|
|
3228
|
+
* types. Inspection code often types events loosely or lives in plain
|
|
3229
|
+
* JavaScript, where comparing against a removed type compiles and never
|
|
3230
|
+
* matches. Scans every file, with or without an `xstate` import.
|
|
3231
|
+
*/
|
|
3232
|
+
const reportInspectionEvents = {
|
|
3233
|
+
name: 'report-inspection-events',
|
|
3234
|
+
description: "Detect (report only) v5 inspection event types such as '@xstate.snapshot', which v6 never emits.",
|
|
3235
|
+
apply(sourceFile) {
|
|
3236
|
+
const notes = [];
|
|
3237
|
+
const sites = [];
|
|
3238
|
+
const filePath = sourceFile.getFilePath();
|
|
3239
|
+
const literals = [...sourceFile.getDescendantsOfKind(SyntaxKind.StringLiteral), ...sourceFile.getDescendantsOfKind(SyntaxKind.NoSubstitutionTemplateLiteral)].sort((a, b) => a.getStart() - b.getStart());
|
|
3240
|
+
for (const literal of literals) {
|
|
3241
|
+
if (Node.isImportDeclaration(literal.getParent())) continue;
|
|
3242
|
+
const api = literal.getLiteralText();
|
|
3243
|
+
const hint = HINTS[api];
|
|
3244
|
+
if (!hint) continue;
|
|
3245
|
+
const {
|
|
3246
|
+
line,
|
|
3247
|
+
column
|
|
3248
|
+
} = sourceFile.getLineAndColumnAtPos(literal.getStart());
|
|
3249
|
+
notes.push(`${filePath}:${line}:${column} — '${api}' → ${hint}`);
|
|
3250
|
+
sites.push({
|
|
3251
|
+
file: filePath,
|
|
3252
|
+
line,
|
|
3253
|
+
column,
|
|
3254
|
+
api,
|
|
3255
|
+
hint
|
|
3256
|
+
});
|
|
655
3257
|
}
|
|
656
3258
|
|
|
657
3259
|
// Never mutates the file.
|
|
658
3260
|
return {
|
|
659
3261
|
changed: false,
|
|
660
|
-
notes
|
|
3262
|
+
notes,
|
|
3263
|
+
sites
|
|
661
3264
|
};
|
|
662
3265
|
}
|
|
663
3266
|
};
|
|
664
3267
|
|
|
665
3268
|
/** All transforms, in recommended application order. */
|
|
666
|
-
const transforms = [renameImports, stringTargets, typesToSchemas, reportRemovedApis, reportIdPathTargets];
|
|
3269
|
+
const transforms = [renameImports, stringTargets, typesToSchemas, lowerActions, reportRemovedApis, reportIdPathTargets, suffixedLifecycleEvents, reportActionShapes, reportImplementations, reportInspectionEvents];
|
|
667
3270
|
const transformsByName = Object.fromEntries(transforms.map(t => [t.name, t]));
|
|
668
3271
|
|
|
669
|
-
/** Report-only transforms
|
|
670
|
-
const
|
|
3272
|
+
/** Report-only transforms never rewrite; their notes list manual migration steps. */
|
|
3273
|
+
const isReportTransform = name => name.startsWith('report-');
|
|
671
3274
|
/** Resolve a list of transform names to Transform objects. */
|
|
672
3275
|
function selectTransforms(names) {
|
|
673
3276
|
if (!names || names.length === 0) {
|
|
@@ -693,7 +3296,8 @@ function applyToSourceFile(sourceFile, transforms) {
|
|
|
693
3296
|
perTransform.push({
|
|
694
3297
|
transform: transform.name,
|
|
695
3298
|
changed: result.changed,
|
|
696
|
-
notes: result.notes
|
|
3299
|
+
notes: result.notes,
|
|
3300
|
+
sites: result.sites ?? []
|
|
697
3301
|
});
|
|
698
3302
|
if (result.changed) {
|
|
699
3303
|
changed = true;
|
|
@@ -732,14 +3336,22 @@ function run(filePaths, transforms, options = {
|
|
|
732
3336
|
});
|
|
733
3337
|
const files = [];
|
|
734
3338
|
const manualMigrationNotes = [];
|
|
3339
|
+
const manualSites = [];
|
|
735
3340
|
let filesChanged = 0;
|
|
736
3341
|
for (const filePath of filePaths) {
|
|
737
3342
|
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
738
3343
|
const report = applyToSourceFile(sourceFile, transforms);
|
|
739
3344
|
for (const pt of report.perTransform) {
|
|
740
|
-
if (
|
|
3345
|
+
if (isReportTransform(pt.transform)) {
|
|
741
3346
|
manualMigrationNotes.push(...pt.notes);
|
|
742
3347
|
}
|
|
3348
|
+
// Any transform may leave code it could not migrate.
|
|
3349
|
+
for (const site of pt.sites) {
|
|
3350
|
+
manualSites.push({
|
|
3351
|
+
...site,
|
|
3352
|
+
transform: pt.transform
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
743
3355
|
}
|
|
744
3356
|
if (report.changed) {
|
|
745
3357
|
filesChanged++;
|
|
@@ -752,7 +3364,8 @@ function run(filePaths, transforms, options = {
|
|
|
752
3364
|
return {
|
|
753
3365
|
files,
|
|
754
3366
|
filesChanged,
|
|
755
|
-
manualMigrationNotes
|
|
3367
|
+
manualMigrationNotes,
|
|
3368
|
+
manualSites
|
|
756
3369
|
};
|
|
757
3370
|
}
|
|
758
3371
|
|
|
@@ -765,6 +3378,7 @@ function parseArgs(argv) {
|
|
|
765
3378
|
command: undefined,
|
|
766
3379
|
globs: [],
|
|
767
3380
|
dry: false,
|
|
3381
|
+
json: false,
|
|
768
3382
|
transformNames: undefined,
|
|
769
3383
|
help: false
|
|
770
3384
|
};
|
|
@@ -774,6 +3388,8 @@ function parseArgs(argv) {
|
|
|
774
3388
|
parsed.help = true;
|
|
775
3389
|
} else if (arg === '--dry') {
|
|
776
3390
|
parsed.dry = true;
|
|
3391
|
+
} else if (arg === '--json') {
|
|
3392
|
+
parsed.json = true;
|
|
777
3393
|
} else if (arg === '--transform') {
|
|
778
3394
|
const value = argv[++i];
|
|
779
3395
|
if (value) {
|
|
@@ -791,7 +3407,7 @@ function parseArgs(argv) {
|
|
|
791
3407
|
}
|
|
792
3408
|
function printHelp() {
|
|
793
3409
|
const names = transforms.map(t => ` ${t.name} — ${t.description}`);
|
|
794
|
-
process.stdout.write(['xstate-codemod — migrate XState source (v5 → v6)', '', 'Usage:', ' xstate-codemod migrate [globs...] [--dry] [--transform <name,name>]', '', 'Options:', ' --dry Print a summary of changes without writing files', ' --transform <names> Comma-separated transform names (default: all)', ' -h, --help Show this help', '', `Default glob: ${DEFAULT_GLOB} (excludes node_modules and dist)`, '', 'Transforms:', ...names, ''].join('\n'));
|
|
3410
|
+
process$1.stdout.write(['xstate-codemod — migrate XState source (v5 → v6)', '', 'Usage:', ' xstate-codemod migrate [globs...] [--dry] [--json] [--transform <name,name>]', ' xstate-codemod baseline <v5-file> [--out <path>]', ' xstate-codemod verify --baseline <path> <v6-file>', ' xstate-codemod verify <v5-file> <v6-file>', '', 'Run `xstate-codemod verify --help` for the baseline/verify options.', '', 'Options:', ' --dry Print a summary of changes without writing files', ' --json Print one JSON report to stdout instead of text', ' --transform <names> Comma-separated transform names (default: all)', ' -h, --help Show this help', '', `Default glob: ${DEFAULT_GLOB} (excludes node_modules and dist)`, '', 'Exit codes:', ' 0 No manual migration needed', ' 1 Usage error or invalid migration output', ' 2 Manual migration needed', '', 'Transforms:', ...names, ''].join('\n'));
|
|
795
3411
|
}
|
|
796
3412
|
|
|
797
3413
|
/**
|
|
@@ -818,24 +3434,67 @@ function main(argv) {
|
|
|
818
3434
|
return args.command ? 0 : args.help ? 0 : 1;
|
|
819
3435
|
}
|
|
820
3436
|
if (args.command !== 'migrate') {
|
|
821
|
-
process.stderr.write(`Unknown command '${args.command}'.
|
|
3437
|
+
process$1.stderr.write(`Unknown command '${args.command}'. Expected 'migrate', 'baseline' or 'verify'.\n`);
|
|
822
3438
|
return 1;
|
|
823
3439
|
}
|
|
824
3440
|
let transforms;
|
|
825
3441
|
try {
|
|
826
3442
|
transforms = selectTransforms(args.transformNames);
|
|
827
3443
|
} catch (err) {
|
|
828
|
-
process.stderr.write(`${err.message}\n`);
|
|
3444
|
+
process$1.stderr.write(`${err.message}\n`);
|
|
829
3445
|
return 1;
|
|
830
3446
|
}
|
|
831
3447
|
const files = resolveFiles(args.globs);
|
|
832
|
-
if (files.length === 0) {
|
|
833
|
-
process.stdout.write('No files matched.\n');
|
|
3448
|
+
if (files.length === 0 && !args.json) {
|
|
3449
|
+
process$1.stdout.write('No files matched.\n');
|
|
834
3450
|
return 0;
|
|
835
3451
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
3452
|
+
let report;
|
|
3453
|
+
try {
|
|
3454
|
+
report = run(files, transforms, {
|
|
3455
|
+
write: !args.dry
|
|
3456
|
+
});
|
|
3457
|
+
} catch (err) {
|
|
3458
|
+
const message = err.message;
|
|
3459
|
+
if (!message.startsWith('Refusing to write invalid migration output')) {
|
|
3460
|
+
throw err;
|
|
3461
|
+
}
|
|
3462
|
+
if (args.json) {
|
|
3463
|
+
process$1.stdout.write(`${JSON.stringify({
|
|
3464
|
+
error: {
|
|
3465
|
+
code: 'INVALID_OUTPUT',
|
|
3466
|
+
message
|
|
3467
|
+
}
|
|
3468
|
+
})}\n`);
|
|
3469
|
+
} else {
|
|
3470
|
+
process$1.stderr.write(`${message}\n`);
|
|
3471
|
+
}
|
|
3472
|
+
return 1;
|
|
3473
|
+
}
|
|
3474
|
+
const exitCode = report.manualSites.length > 0 ? 2 : 0;
|
|
3475
|
+
if (args.json) {
|
|
3476
|
+
process$1.stdout.write(`${JSON.stringify({
|
|
3477
|
+
filesChanged: report.filesChanged,
|
|
3478
|
+
files: report.files.map(file => ({
|
|
3479
|
+
filePath: file.filePath,
|
|
3480
|
+
changed: file.changed,
|
|
3481
|
+
transforms: file.perTransform.map(pt => ({
|
|
3482
|
+
name: pt.transform,
|
|
3483
|
+
changed: pt.changed,
|
|
3484
|
+
notes: pt.notes
|
|
3485
|
+
}))
|
|
3486
|
+
})),
|
|
3487
|
+
manualSites: report.manualSites.map(site => ({
|
|
3488
|
+
file: site.file,
|
|
3489
|
+
line: site.line,
|
|
3490
|
+
column: site.column,
|
|
3491
|
+
api: site.api,
|
|
3492
|
+
hint: site.hint,
|
|
3493
|
+
transform: site.transform
|
|
3494
|
+
}))
|
|
3495
|
+
}, null, 2)}\n`);
|
|
3496
|
+
return exitCode;
|
|
3497
|
+
}
|
|
839
3498
|
|
|
840
3499
|
// Per-file, per-transform summary.
|
|
841
3500
|
for (const file of report.files) {
|
|
@@ -843,27 +3502,1236 @@ function main(argv) {
|
|
|
843
3502
|
if (touched.length === 0) {
|
|
844
3503
|
continue;
|
|
845
3504
|
}
|
|
846
|
-
process.stdout.write(`\n${file.filePath}${file.changed ? '' : ' (no writes)'}\n`);
|
|
3505
|
+
process$1.stdout.write(`\n${file.filePath}${file.changed ? '' : ' (no writes)'}\n`);
|
|
847
3506
|
for (const pt of touched) {
|
|
848
3507
|
const mark = pt.changed ? '✔' : '·';
|
|
849
|
-
process.stdout.write(` ${mark} ${pt.transform}\n`);
|
|
3508
|
+
process$1.stdout.write(` ${mark} ${pt.transform}\n`);
|
|
850
3509
|
for (const note of pt.notes) {
|
|
851
|
-
process.stdout.write(` - ${note}\n`);
|
|
3510
|
+
process$1.stdout.write(` - ${note}\n`);
|
|
852
3511
|
}
|
|
853
3512
|
}
|
|
854
3513
|
}
|
|
855
3514
|
|
|
856
3515
|
// Manual-migration section.
|
|
857
3516
|
if (report.manualMigrationNotes.length) {
|
|
858
|
-
process.stdout.write('\n=== Manual migration needed ===\n');
|
|
3517
|
+
process$1.stdout.write('\n=== Manual migration needed ===\n');
|
|
859
3518
|
for (const note of report.manualMigrationNotes) {
|
|
860
|
-
process.stdout.write(` ${note}\n`);
|
|
3519
|
+
process$1.stdout.write(` ${note}\n`);
|
|
861
3520
|
}
|
|
862
3521
|
}
|
|
863
3522
|
|
|
864
3523
|
// Final report.
|
|
865
|
-
process.stdout.write(`\n${args.dry ? '[dry run] ' : ''}${report.filesChanged} of ${files.length} file(s) ${args.dry ? 'would change' : 'changed'}.\n`);
|
|
866
|
-
return
|
|
3524
|
+
process$1.stdout.write(`\n${args.dry ? '[dry run] ' : ''}${report.filesChanged} of ${files.length} file(s) ${args.dry ? 'would change' : 'changed'}.\n`);
|
|
3525
|
+
return exitCode;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
// v5 spawn (`x:3`), v5 invoke (`0.root.a`), v6 invoke/spawn (`root.a:0`, `child:0`).
|
|
3529
|
+
const ANON_ID = /^(x:\d+|\d+\..+|.+:\d+)$/;
|
|
3530
|
+
function normalizeId(id) {
|
|
3531
|
+
return ANON_ID.test(id) ? '(anon)' : id;
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
/**
|
|
3535
|
+
* v5 gives an id-less machine the root id `(machine)` and prefixes every
|
|
3536
|
+
* implicit state id with it; v6 leaves the prefix out.
|
|
3537
|
+
*/
|
|
3538
|
+
function normalizeStateId(id) {
|
|
3539
|
+
if (id === '(machine)') return '';
|
|
3540
|
+
return id.startsWith('(machine).') ? id.slice('(machine).'.length) : id;
|
|
3541
|
+
}
|
|
3542
|
+
function isActorRef(value) {
|
|
3543
|
+
return typeof value.send === 'function' && typeof value.getSnapshot === 'function' && typeof value.id === 'string';
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
/**
|
|
3547
|
+
* JSON round-trip that marks functions/symbols and replaces actor refs.
|
|
3548
|
+
* Objects nested deeper than `maxDepth` become `'[deep]'`.
|
|
3549
|
+
*/
|
|
3550
|
+
function toJson(value, stats, seen = new Set(), maxDepth = Infinity) {
|
|
3551
|
+
switch (typeof value) {
|
|
3552
|
+
case 'undefined':
|
|
3553
|
+
case 'string':
|
|
3554
|
+
case 'boolean':
|
|
3555
|
+
return value;
|
|
3556
|
+
case 'number':
|
|
3557
|
+
return Number.isFinite(value) ? value : null;
|
|
3558
|
+
case 'bigint':
|
|
3559
|
+
return String(value);
|
|
3560
|
+
case 'function':
|
|
3561
|
+
stats.nonSerializable++;
|
|
3562
|
+
return '[fn]';
|
|
3563
|
+
case 'symbol':
|
|
3564
|
+
stats.nonSerializable++;
|
|
3565
|
+
return '[symbol]';
|
|
3566
|
+
}
|
|
3567
|
+
if (value === null) return null;
|
|
3568
|
+
const obj = value;
|
|
3569
|
+
if (isActorRef(obj)) return {
|
|
3570
|
+
$actor: normalizeId(obj.id)
|
|
3571
|
+
};
|
|
3572
|
+
if (obj instanceof Error) return String(obj);
|
|
3573
|
+
if (typeof obj.toJSON === 'function') return toJson(obj.toJSON(), stats, seen, maxDepth);
|
|
3574
|
+
if (seen.has(obj)) return '[circular]';
|
|
3575
|
+
if (seen.size >= maxDepth) return '[deep]';
|
|
3576
|
+
seen.add(obj);
|
|
3577
|
+
try {
|
|
3578
|
+
if (Array.isArray(obj)) {
|
|
3579
|
+
return obj.map(item => toJson(item, stats, seen, maxDepth) ?? null);
|
|
3580
|
+
}
|
|
3581
|
+
const out = {};
|
|
3582
|
+
for (const key of Object.keys(obj)) {
|
|
3583
|
+
const item = toJson(obj[key], stats, seen, maxDepth);
|
|
3584
|
+
if (item !== undefined) out[key] = item;
|
|
3585
|
+
}
|
|
3586
|
+
return out;
|
|
3587
|
+
} finally {
|
|
3588
|
+
seen.delete(obj);
|
|
3589
|
+
}
|
|
3590
|
+
}
|
|
3591
|
+
function withOutput(event, output, stats) {
|
|
3592
|
+
const json = toJson(output, stats);
|
|
3593
|
+
return json === undefined ? event : {
|
|
3594
|
+
...event,
|
|
3595
|
+
output: json
|
|
3596
|
+
};
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/** Maps v5 suffixed and v6 payload-identified lifecycle events to one shape. */
|
|
3600
|
+
function normalizeEvent(event, stats) {
|
|
3601
|
+
const {
|
|
3602
|
+
type,
|
|
3603
|
+
sessionId: _sessionId,
|
|
3604
|
+
...rest
|
|
3605
|
+
} = event;
|
|
3606
|
+
let match;
|
|
3607
|
+
if (type === 'xstate.done.actor' || (match = /^xstate\.done\.actor\.(.+)$/.exec(type))) {
|
|
3608
|
+
const actorId = normalizeId(rest.actorId ?? match[1]);
|
|
3609
|
+
return withOutput({
|
|
3610
|
+
type: 'xstate.done.actor',
|
|
3611
|
+
actorId
|
|
3612
|
+
}, rest.output, stats);
|
|
3613
|
+
}
|
|
3614
|
+
if (type === 'xstate.error.actor' || (match = /^xstate\.error\.actor\.(.+)$/.exec(type))) {
|
|
3615
|
+
return {
|
|
3616
|
+
type: 'xstate.error.actor',
|
|
3617
|
+
actorId: normalizeId(rest.actorId ?? match[1]),
|
|
3618
|
+
error: String(rest.error)
|
|
3619
|
+
};
|
|
3620
|
+
}
|
|
3621
|
+
if (type === 'xstate.done.state' || (match = /^xstate\.done\.state\.(.+)$/.exec(type))) {
|
|
3622
|
+
const stateId = normalizeStateId(rest.stateId ?? match[1]);
|
|
3623
|
+
return withOutput({
|
|
3624
|
+
type: 'xstate.done.state',
|
|
3625
|
+
stateId
|
|
3626
|
+
}, rest.output, stats);
|
|
3627
|
+
}
|
|
3628
|
+
if (type === 'xstate.after') {
|
|
3629
|
+
return {
|
|
3630
|
+
type,
|
|
3631
|
+
delay: rest.delay,
|
|
3632
|
+
stateId: normalizeStateId(rest.stateId)
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
if (match = /^xstate\.after\.([^.]+)\.(.+)$/.exec(type)) {
|
|
3636
|
+
const delay = Number(match[1]);
|
|
3637
|
+
return {
|
|
3638
|
+
type: 'xstate.after',
|
|
3639
|
+
delay: Number.isFinite(delay) ? delay : match[1],
|
|
3640
|
+
stateId: normalizeStateId(match[2])
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
return {
|
|
3644
|
+
type,
|
|
3645
|
+
...toJson(rest, stats)
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/** JSON with sorted object keys, for order-insensitive equality. */
|
|
3650
|
+
function canonical(value) {
|
|
3651
|
+
return JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
3652
|
+
}
|
|
3653
|
+
function isEqual(a, b) {
|
|
3654
|
+
return canonical(a) === canonical(b);
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
function mapDeep(value, fn) {
|
|
3658
|
+
if (Array.isArray(value)) return value.map(item => mapDeep(item, fn));
|
|
3659
|
+
if (value && typeof value === 'object') {
|
|
3660
|
+
const out = {};
|
|
3661
|
+
for (const [key, item] of Object.entries(value)) {
|
|
3662
|
+
const mapped = fn(key, item);
|
|
3663
|
+
if (mapped !== undefined) out[key] = mapDeep(mapped, fn);
|
|
3664
|
+
}
|
|
3665
|
+
return out;
|
|
3666
|
+
}
|
|
3667
|
+
return value;
|
|
3668
|
+
}
|
|
3669
|
+
const stripAnonSuffix = value => mapDeep(value, (key, item) => (key === 'id' || key === 'to') && typeof item === 'string' ? item.replace(/(:\d+|\[\d+\])$/, '').replace(/^(x:\d+|\d+\.)/, '') : item);
|
|
3670
|
+
const isActorMarker = item => !!item && typeof item === 'object' && '$actor' in item;
|
|
3671
|
+
const stripErrorPrefix = message => message.replace(/^(\w*Error:\s*)+/, '').trim();
|
|
3672
|
+
const INTENTIONAL_DELTAS = [{
|
|
3673
|
+
rule: 'anonymous-child-id',
|
|
3674
|
+
section: '§12',
|
|
3675
|
+
note: 'anonymous child ids are generated differently',
|
|
3676
|
+
matches: (field, expected, actual) => (field === 'children' || field === 'sent') && isEqual(stripAnonSuffix(expected), stripAnonSuffix(actual))
|
|
3677
|
+
}, {
|
|
3678
|
+
rule: 'error-string-shape',
|
|
3679
|
+
section: '§16',
|
|
3680
|
+
note: 'same error message, different error class or prefix',
|
|
3681
|
+
matches: (field, expected, actual) => field === 'error' && typeof expected === 'string' && typeof actual === 'string' && expected !== '' && actual !== '' && stripErrorPrefix(expected) === stripErrorPrefix(actual)
|
|
3682
|
+
}, {
|
|
3683
|
+
rule: 'context-reference',
|
|
3684
|
+
section: '§20',
|
|
3685
|
+
note: 'context differs only in stored actor references',
|
|
3686
|
+
matches: (field, expected, actual) => field === 'context' && isEqual(mapDeep(expected, (_key, item) => isActorMarker(item) ? undefined : item), mapDeep(actual, (_key, item) => isActorMarker(item) ? undefined : item))
|
|
3687
|
+
}, {
|
|
3688
|
+
rule: 'done-event-session',
|
|
3689
|
+
section: '§12',
|
|
3690
|
+
note: 'done events differ only by sessionId',
|
|
3691
|
+
matches: (field, expected, actual) => field === 'processed' && isEqual(mapDeep(expected, (key, item) => key === 'sessionId' ? undefined : item), mapDeep(actual, (key, item) => key === 'sessionId' ? undefined : item))
|
|
3692
|
+
}, {
|
|
3693
|
+
rule: 'final-state-output',
|
|
3694
|
+
section: '§3',
|
|
3695
|
+
note: "v6 promotes a top-level final state's output to snapshot.output when the root has none; v5 left it undefined",
|
|
3696
|
+
matches: (field, expected, actual) => field === 'output' && expected === undefined && actual !== undefined
|
|
3697
|
+
}, {
|
|
3698
|
+
rule: 'root-send-parent',
|
|
3699
|
+
section: '§26',
|
|
3700
|
+
note: "v5 errors when a root actor sends to its parent; v6 dead-letters the event with reason 'missingTarget'",
|
|
3701
|
+
matches: (field, _expected, _actual, _step, steps) => field === 'status' && steps.expected.status === 'error' && steps.actual.status !== 'error' && /#_parent|Unable to send event to actor/.test(steps.expected.error ?? '')
|
|
3702
|
+
}];
|
|
3703
|
+
|
|
3704
|
+
const FIELD_ORDER = ['status', 'value', 'context', 'output', 'error', 'processed', 'children', 'sent', 'emitted', 'actions'];
|
|
3705
|
+
const DEFAULT_FIELDS = FIELD_ORDER.filter(f => f !== 'actions');
|
|
3706
|
+
function classify(field, expected, actual, step, steps) {
|
|
3707
|
+
const rule = INTENTIONAL_DELTAS.find(delta => delta.matches(field, expected, actual, step, steps));
|
|
3708
|
+
return rule ? {
|
|
3709
|
+
rule: rule.rule,
|
|
3710
|
+
section: rule.section,
|
|
3711
|
+
note: rule.note
|
|
3712
|
+
} : 'unclassified';
|
|
3713
|
+
}
|
|
3714
|
+
function errorDetail(expected, actual) {
|
|
3715
|
+
const parts = [];
|
|
3716
|
+
if (expected.status === 'error') parts.push(`v5 error: ${expected.error}`);
|
|
3717
|
+
if (actual.status === 'error') parts.push(`v6 error: ${actual.error}`);
|
|
3718
|
+
return parts.join('; ') || undefined;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
/**
|
|
3722
|
+
* Compares traces step by step. An unclassified mismatch ends its sequence;
|
|
3723
|
+
* classified (intentional) mismatches are recorded and comparison continues so
|
|
3724
|
+
* they cannot mask a later real divergence.
|
|
3725
|
+
*/
|
|
3726
|
+
function diffTraces(sequences, expected, actual, fields = DEFAULT_FIELDS) {
|
|
3727
|
+
const diffs = [];
|
|
3728
|
+
let stepsCompared = 0;
|
|
3729
|
+
const compared = FIELD_ORDER.filter(field => fields.includes(field));
|
|
3730
|
+
for (const sequence of sequences) {
|
|
3731
|
+
const exp = expected.find(t => t.sequenceId === sequence.id);
|
|
3732
|
+
const act = actual.find(t => t.sequenceId === sequence.id);
|
|
3733
|
+
if (!exp || !act) continue;
|
|
3734
|
+
const length = Math.min(exp.steps.length, act.steps.length);
|
|
3735
|
+
outer: for (let step = 0; step < length; step++) {
|
|
3736
|
+
stepsCompared++;
|
|
3737
|
+
const expStep = exp.steps[step];
|
|
3738
|
+
const actStep = act.steps[step];
|
|
3739
|
+
// v6 errored snapshots carry no value (v5 keeps the last one), so when
|
|
3740
|
+
// both sides errored only the status and error are meaningful.
|
|
3741
|
+
const bothErrored = expStep.status === 'error' && actStep.status === 'error';
|
|
3742
|
+
for (const field of compared) {
|
|
3743
|
+
if (bothErrored && field !== 'status' && field !== 'error') continue;
|
|
3744
|
+
const e = expStep[field];
|
|
3745
|
+
const a = actStep[field];
|
|
3746
|
+
if (isEqual(e, a)) continue;
|
|
3747
|
+
const classification = classify(field, e, a, step, {
|
|
3748
|
+
expected: expStep,
|
|
3749
|
+
actual: actStep
|
|
3750
|
+
});
|
|
3751
|
+
const detail = field === 'status' ? errorDetail(expStep, actStep) : undefined;
|
|
3752
|
+
diffs.push({
|
|
3753
|
+
sequenceId: sequence.id,
|
|
3754
|
+
origin: sequence.origin,
|
|
3755
|
+
step,
|
|
3756
|
+
witness: sequence.steps.slice(0, step),
|
|
3757
|
+
field,
|
|
3758
|
+
expected: e,
|
|
3759
|
+
actual: a,
|
|
3760
|
+
...(detail && {
|
|
3761
|
+
detail
|
|
3762
|
+
}),
|
|
3763
|
+
classification
|
|
3764
|
+
});
|
|
3765
|
+
if (classification === 'unclassified') break outer;
|
|
3766
|
+
// An intentional status divergence (one side errored, the other kept
|
|
3767
|
+
// running) makes every later field and step incomparable.
|
|
3768
|
+
if (field === 'status') break outer;
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
diffs.sort((a, b) => Number(a.classification !== 'unclassified') - Number(b.classification !== 'unclassified'));
|
|
3773
|
+
return {
|
|
3774
|
+
diffs,
|
|
3775
|
+
stepsCompared
|
|
3776
|
+
};
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
/** Generation note for a graph traversal that never closed. */
|
|
3780
|
+
const TRAVERSAL_LIMIT_NOTE = 'graph traversal hit its limit (context that changes on every step prevents the graph from closing); random walks still cover these paths';
|
|
3781
|
+
function mulberry32(seed) {
|
|
3782
|
+
let a = seed >>> 0;
|
|
3783
|
+
return () => {
|
|
3784
|
+
a = a + 0x6d2b79f5 >>> 0;
|
|
3785
|
+
let t = a;
|
|
3786
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
3787
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
3788
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
3789
|
+
};
|
|
3790
|
+
}
|
|
3791
|
+
function eventCandidates(machine, cases) {
|
|
3792
|
+
const events = [];
|
|
3793
|
+
const withoutCases = [];
|
|
3794
|
+
const types = [...new Set(machine.events)].filter(type => !type.startsWith('xstate.') && !type.includes('*'));
|
|
3795
|
+
for (const type of types) {
|
|
3796
|
+
const payloads = cases[type];
|
|
3797
|
+
if (payloads?.length) {
|
|
3798
|
+
for (const payload of payloads) events.push({
|
|
3799
|
+
...payload,
|
|
3800
|
+
type
|
|
3801
|
+
});
|
|
3802
|
+
} else {
|
|
3803
|
+
withoutCases.push(type);
|
|
3804
|
+
events.push({
|
|
3805
|
+
type
|
|
3806
|
+
});
|
|
3807
|
+
}
|
|
3808
|
+
}
|
|
3809
|
+
return {
|
|
3810
|
+
events,
|
|
3811
|
+
withoutCases
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
|
|
3815
|
+
/**
|
|
3816
|
+
* Advancing exactly to the next deadline fires one timer per step: v5's
|
|
3817
|
+
* SimulatedClock does not fire timers scheduled during an increment while v6's
|
|
3818
|
+
* does, so a longer jump would diverge on clock semantics alone.
|
|
3819
|
+
*/
|
|
3820
|
+
function untilNextTimer(clock) {
|
|
3821
|
+
// SimulatedClock keeps pending timeouts in a private map.
|
|
3822
|
+
let next = Infinity;
|
|
3823
|
+
for (const {
|
|
3824
|
+
start,
|
|
3825
|
+
timeout
|
|
3826
|
+
} of clock.timeouts.values()) next = Math.min(next, start + timeout);
|
|
3827
|
+
return next === Infinity ? undefined : next - clock.now();
|
|
3828
|
+
}
|
|
3829
|
+
const flush$1 = async () => {
|
|
3830
|
+
for (let i = 0; i < 3; i++) await new Promise(resolve => setImmediate(resolve));
|
|
3831
|
+
};
|
|
3832
|
+
|
|
3833
|
+
/** Sequences from the v5 graph (shortest, simple) and seeded random walks. */
|
|
3834
|
+
async function generateSequences(xs, graph, machine, events, options, input) {
|
|
3835
|
+
const raw = [];
|
|
3836
|
+
const generationErrors = [];
|
|
3837
|
+
const fromPaths = async (origin, paths) => {
|
|
3838
|
+
let result;
|
|
3839
|
+
try {
|
|
3840
|
+
// The wall clock stays at the epoch during traversal so context that
|
|
3841
|
+
// stores `Date.now()` does not keep the graph from closing.
|
|
3842
|
+
result = await withDeterminism({
|
|
3843
|
+
now: () => 0
|
|
3844
|
+
}, options.seed, paths);
|
|
3845
|
+
} catch (err) {
|
|
3846
|
+
// Unbounded context makes the state space infinite, and a guard that
|
|
3847
|
+
// reads a payload the event cases do not supply throws. Either way the
|
|
3848
|
+
// origin is dropped and reported; random walks still run.
|
|
3849
|
+
const message = err.message ?? String(err);
|
|
3850
|
+
generationErrors.push(`${origin} paths skipped: ${message === 'Traversal limit exceeded' ? TRAVERSAL_LIMIT_NOTE : message}`);
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
for (const path of result) {
|
|
3854
|
+
raw.push({
|
|
3855
|
+
origin,
|
|
3856
|
+
steps: path.steps.filter(step => step.event.type !== 'xstate.init').map(step => ({
|
|
3857
|
+
event: step.event
|
|
3858
|
+
}))
|
|
3859
|
+
});
|
|
3860
|
+
}
|
|
3861
|
+
};
|
|
3862
|
+
const traversal = {
|
|
3863
|
+
events,
|
|
3864
|
+
limit: 1000,
|
|
3865
|
+
input
|
|
3866
|
+
};
|
|
3867
|
+
await fromPaths('shortest', () => graph.getShortestPaths(machine, traversal));
|
|
3868
|
+
if (options.simplePaths === false) generationErrors.push('simple paths skipped (--no-simple-paths)');else await fromPaths('simple', () => graph.getSimplePaths(machine, traversal));
|
|
3869
|
+
const random = mulberry32(options.seed);
|
|
3870
|
+
const randomWalk = async clock => {
|
|
3871
|
+
const actor = xs.createActor(machine, {
|
|
3872
|
+
clock,
|
|
3873
|
+
input
|
|
3874
|
+
});
|
|
3875
|
+
actor.subscribe({
|
|
3876
|
+
error: () => {}
|
|
3877
|
+
});
|
|
3878
|
+
actor.start();
|
|
3879
|
+
await flush$1();
|
|
3880
|
+
const steps = [];
|
|
3881
|
+
while (steps.length < options.steps && actor.getSnapshot().status === 'active') {
|
|
3882
|
+
const snapshot = actor.getSnapshot();
|
|
3883
|
+
const candidates = events.filter(event => {
|
|
3884
|
+
try {
|
|
3885
|
+
return snapshot.can(event);
|
|
3886
|
+
} catch (err) {
|
|
3887
|
+
// A guard threw on this payload; drop the event for this walk.
|
|
3888
|
+
const note = `event '${event.type}' skipped: guard threw: ${err.message ?? String(err)}`;
|
|
3889
|
+
if (!generationErrors.includes(note)) generationErrors.push(note);
|
|
3890
|
+
return false;
|
|
3891
|
+
}
|
|
3892
|
+
}).map(event => ({
|
|
3893
|
+
event
|
|
3894
|
+
}));
|
|
3895
|
+
const ms = untilNextTimer(clock);
|
|
3896
|
+
if (ms !== undefined) candidates.push({
|
|
3897
|
+
advance: ms
|
|
3898
|
+
});
|
|
3899
|
+
if (!candidates.length) break;
|
|
3900
|
+
const next = candidates[Math.floor(random() * candidates.length)];
|
|
3901
|
+
steps.push(next);
|
|
3902
|
+
if ('event' in next) actor.send(next.event);else clock.increment(next.advance);
|
|
3903
|
+
await flush$1();
|
|
3904
|
+
}
|
|
3905
|
+
actor.stop();
|
|
3906
|
+
return steps;
|
|
3907
|
+
};
|
|
3908
|
+
for (let walk = 0; walk < options.walks; walk++) {
|
|
3909
|
+
const clock = new xs.SimulatedClock();
|
|
3910
|
+
const steps = await withDeterminism(clock, options.seed + walk, () => randomWalk(clock));
|
|
3911
|
+
raw.push({
|
|
3912
|
+
origin: 'random',
|
|
3913
|
+
steps
|
|
3914
|
+
});
|
|
3915
|
+
}
|
|
3916
|
+
const seen = new Set();
|
|
3917
|
+
const counts = {
|
|
3918
|
+
shortest: 0,
|
|
3919
|
+
simple: 0,
|
|
3920
|
+
random: 0
|
|
3921
|
+
};
|
|
3922
|
+
const sequences = [];
|
|
3923
|
+
for (const {
|
|
3924
|
+
origin,
|
|
3925
|
+
steps
|
|
3926
|
+
} of raw) {
|
|
3927
|
+
const key = JSON.stringify(steps);
|
|
3928
|
+
if (seen.has(key)) continue;
|
|
3929
|
+
seen.add(key);
|
|
3930
|
+
if (sequences.length >= options.maxSequences) break;
|
|
3931
|
+
sequences.push({
|
|
3932
|
+
id: `${origin}-${counts[origin]++}`,
|
|
3933
|
+
origin,
|
|
3934
|
+
steps
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
return {
|
|
3938
|
+
sequences,
|
|
3939
|
+
generationErrors
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
/** `Date.now()` at simulated time 0. */
|
|
3944
|
+
const EPOCH = 1_700_000_000_000;
|
|
3945
|
+
|
|
3946
|
+
/**
|
|
3947
|
+
* Runs `fn` with `Date.now`, `performance.now`, and `Math.random` driven by
|
|
3948
|
+
* the simulated clock and a seeded generator, so both majors read identical
|
|
3949
|
+
* values. The originals are restored afterwards.
|
|
3950
|
+
*/
|
|
3951
|
+
async function withDeterminism(clock, seed, fn) {
|
|
3952
|
+
const {
|
|
3953
|
+
now
|
|
3954
|
+
} = Date;
|
|
3955
|
+
const perfNow = performance.now;
|
|
3956
|
+
const {
|
|
3957
|
+
random
|
|
3958
|
+
} = Math;
|
|
3959
|
+
Date.now = () => EPOCH + clock.now();
|
|
3960
|
+
performance.now = () => clock.now();
|
|
3961
|
+
Math.random = mulberry32(seed);
|
|
3962
|
+
try {
|
|
3963
|
+
return await fn();
|
|
3964
|
+
} finally {
|
|
3965
|
+
Date.now = now;
|
|
3966
|
+
performance.now = perfNow;
|
|
3967
|
+
Math.random = random;
|
|
3968
|
+
}
|
|
3969
|
+
}
|
|
3970
|
+
|
|
3971
|
+
/** Action params can hold whole `args` objects; record only their top levels. */
|
|
3972
|
+
const ACTION_PARAMS_DEPTH$1 = 4;
|
|
3973
|
+
const empty$1 = () => ({
|
|
3974
|
+
processed: [],
|
|
3975
|
+
sent: [],
|
|
3976
|
+
emitted: [],
|
|
3977
|
+
actions: []
|
|
3978
|
+
});
|
|
3979
|
+
|
|
3980
|
+
/**
|
|
3981
|
+
* v5 inspection: `@xstate.snapshot` (one per processed event), `@xstate.event`
|
|
3982
|
+
* (relays; `sourceRef` → `actorRef`), `@xstate.actor`, `@xstate.action`.
|
|
3983
|
+
*/
|
|
3984
|
+
function createRecorder5(stats) {
|
|
3985
|
+
let root;
|
|
3986
|
+
let bucket = empty$1();
|
|
3987
|
+
const registry = new Map();
|
|
3988
|
+
return {
|
|
3989
|
+
setRoot: ref => {
|
|
3990
|
+
root = ref;
|
|
3991
|
+
},
|
|
3992
|
+
inspect(e) {
|
|
3993
|
+
switch (e.type) {
|
|
3994
|
+
case '@xstate.actor':
|
|
3995
|
+
registry.set(e.actorRef, typeof e.actorRef.src === 'string' ? e.actorRef.src : '(inline)');
|
|
3996
|
+
break;
|
|
3997
|
+
case '@xstate.snapshot':
|
|
3998
|
+
if (e.actorRef === root && e.event.type !== 'xstate.init') {
|
|
3999
|
+
bucket.processed.push(normalizeEvent(e.event, stats));
|
|
4000
|
+
}
|
|
4001
|
+
break;
|
|
4002
|
+
case '@xstate.event':
|
|
4003
|
+
if (e.sourceRef === root && e.actorRef !== root && e.event.type !== 'xstate.init' && e.event.type !== 'xstate.stop') {
|
|
4004
|
+
bucket.sent.push({
|
|
4005
|
+
to: normalizeId(e.actorRef.id),
|
|
4006
|
+
event: normalizeEvent(e.event, stats)
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
break;
|
|
4010
|
+
case '@xstate.action':
|
|
4011
|
+
if (e.actorRef === root && !e.action.type.startsWith('xstate.')) {
|
|
4012
|
+
bucket.actions.push({
|
|
4013
|
+
type: e.action.type,
|
|
4014
|
+
params: toJson(e.action.params, stats, undefined, ACTION_PARAMS_DEPTH$1) ?? null
|
|
4015
|
+
});
|
|
4016
|
+
}
|
|
4017
|
+
break;
|
|
4018
|
+
}
|
|
4019
|
+
},
|
|
4020
|
+
emit(event) {
|
|
4021
|
+
bucket.emitted.push(normalizeEvent(event, stats));
|
|
4022
|
+
},
|
|
4023
|
+
srcOf: ref => registry.get(ref) ?? '(inline)',
|
|
4024
|
+
take() {
|
|
4025
|
+
const taken = bucket;
|
|
4026
|
+
bucket = empty$1();
|
|
4027
|
+
return taken;
|
|
4028
|
+
}
|
|
4029
|
+
};
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
/** Action params can hold whole `args` objects; record only their top levels. */
|
|
4033
|
+
const ACTION_PARAMS_DEPTH = 4;
|
|
4034
|
+
const empty = () => ({
|
|
4035
|
+
processed: [],
|
|
4036
|
+
sent: [],
|
|
4037
|
+
emitted: [],
|
|
4038
|
+
actions: []
|
|
4039
|
+
});
|
|
4040
|
+
|
|
4041
|
+
/**
|
|
4042
|
+
* v6 inspection: `@xstate.actor` (`id`, `src`, `parentRef`) and
|
|
4043
|
+
* `@xstate.transition` (`event`, `snapshot`, `actions[]`, `sent[]`).
|
|
4044
|
+
*/
|
|
4045
|
+
function createRecorder6(stats) {
|
|
4046
|
+
let root;
|
|
4047
|
+
let rootSnapshot;
|
|
4048
|
+
let bucket = empty();
|
|
4049
|
+
const registry = new Map();
|
|
4050
|
+
|
|
4051
|
+
// Delayed raises arrive as `xstate.timer` keyed by `{ event }`, `{ seq }` or
|
|
4052
|
+
// `{ id }`; resolve them against the timers of the snapshot that scheduled
|
|
4053
|
+
// them so the trace shows the delivered event, as in v5. A delayed send to
|
|
4054
|
+
// another actor shows up in that firing transition's `sent` instead.
|
|
4055
|
+
function onTimer(event) {
|
|
4056
|
+
if (event.event) {
|
|
4057
|
+
bucket.processed.push(normalizeEvent(event.event, stats));
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
const timer = rootSnapshot?.timers?.find(t => event.id !== undefined ? t.id === event.id : t.seq === event.seq);
|
|
4061
|
+
if (timer && (timer.target === 'self' || timer.target === root)) {
|
|
4062
|
+
bucket.processed.push(normalizeEvent(timer.event, stats));
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
return {
|
|
4066
|
+
setRoot: ref => {
|
|
4067
|
+
root = ref;
|
|
4068
|
+
},
|
|
4069
|
+
inspect(e) {
|
|
4070
|
+
if (e.type === '@xstate.actor') {
|
|
4071
|
+
registry.set(e.actorRef, typeof e.src === 'string' ? e.src : '(inline)');
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
if (e.type !== '@xstate.transition' || e.actorRef !== root) return;
|
|
4075
|
+
const type = e.event.type;
|
|
4076
|
+
if (type === 'xstate.timer') onTimer(e.event);else if (type !== 'xstate.init') bucket.processed.push(normalizeEvent(e.event, stats));
|
|
4077
|
+
for (const action of e.actions) {
|
|
4078
|
+
if (action.type.startsWith('xstate.') || action.type.startsWith('@xstate.')) continue;
|
|
4079
|
+
bucket.actions.push({
|
|
4080
|
+
type: action.type,
|
|
4081
|
+
params: toJson(action.params, stats, undefined, ACTION_PARAMS_DEPTH) ?? null
|
|
4082
|
+
});
|
|
4083
|
+
}
|
|
4084
|
+
for (const sent of e.sent) {
|
|
4085
|
+
// v5 reports a delayed send when it is delivered; so does the timer's
|
|
4086
|
+
// firing transition here.
|
|
4087
|
+
if (sent.targetRef === root || sent.delay !== undefined) continue;
|
|
4088
|
+
bucket.sent.push({
|
|
4089
|
+
to: normalizeId(sent.targetId),
|
|
4090
|
+
event: normalizeEvent(sent.event, stats)
|
|
4091
|
+
});
|
|
4092
|
+
}
|
|
4093
|
+
rootSnapshot = e.snapshot;
|
|
4094
|
+
},
|
|
4095
|
+
emit(event) {
|
|
4096
|
+
bucket.emitted.push(normalizeEvent(event, stats));
|
|
4097
|
+
},
|
|
4098
|
+
srcOf: ref => registry.get(ref) ?? '(inline)',
|
|
4099
|
+
take() {
|
|
4100
|
+
const taken = bucket;
|
|
4101
|
+
bucket = empty();
|
|
4102
|
+
return taken;
|
|
4103
|
+
}
|
|
4104
|
+
};
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
function outcome(spec) {
|
|
4108
|
+
if (spec === 'pending') return () => new Promise(() => {});
|
|
4109
|
+
if ('error' in spec) return () => Promise.reject(new Error(spec.error));
|
|
4110
|
+
return () => Promise.resolve(spec.done);
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
/**
|
|
4114
|
+
* Replaces keyed actors with scripted promise logic. Keyed state machines stay
|
|
4115
|
+
* real: they are deterministic and their behavior is part of the trace.
|
|
4116
|
+
* Everything else defaults to `'pending'` so no real side effect runs.
|
|
4117
|
+
*/
|
|
4118
|
+
function applyStubs(xs, major, machine, stubs) {
|
|
4119
|
+
const keyed = (major === 5 ? machine.implementations?.actors : machine.sources?.actors) ?? {};
|
|
4120
|
+
const actors = {};
|
|
4121
|
+
for (const [key, logic] of Object.entries(keyed)) {
|
|
4122
|
+
const spec = stubs.actors?.[key];
|
|
4123
|
+
if (!spec && logic instanceof xs.StateMachine) continue;
|
|
4124
|
+
const run = outcome(spec ?? 'pending');
|
|
4125
|
+
actors[key] = major === 5 ? xs.fromPromise(run) : xs.createAsyncLogic({
|
|
4126
|
+
run
|
|
4127
|
+
});
|
|
4128
|
+
}
|
|
4129
|
+
return machine.provide({
|
|
4130
|
+
actors,
|
|
4131
|
+
...(stubs.delays && {
|
|
4132
|
+
delays: stubs.delays
|
|
4133
|
+
})
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
/** Marks the start of the JSON result on stdout; user code may print before it. */
|
|
4138
|
+
const RESULT_MARKER = '\n@@xstate-verify-result@@\n';
|
|
4139
|
+
class ChildError extends Error {
|
|
4140
|
+
constructor(code, message) {
|
|
4141
|
+
super(message);
|
|
4142
|
+
this.code = void 0;
|
|
4143
|
+
this.code = code;
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
const isLogic = value => !!value && typeof value.transition === 'function' && typeof value.getInitialSnapshot === 'function';
|
|
4147
|
+
async function loadLogic(file, name) {
|
|
4148
|
+
let mod;
|
|
4149
|
+
try {
|
|
4150
|
+
mod = await import(pathToFileURL(file).href);
|
|
4151
|
+
} catch (err) {
|
|
4152
|
+
const error = err;
|
|
4153
|
+
throw Object.assign(new ChildError('LOAD_ERROR', error.message), {
|
|
4154
|
+
stack: error.stack
|
|
4155
|
+
});
|
|
4156
|
+
}
|
|
4157
|
+
const names = Object.keys(mod);
|
|
4158
|
+
const found = name ? isLogic(mod[name]) && name : isLogic(mod.default) && 'default' || names.find(key => isLogic(mod[key]));
|
|
4159
|
+
if (!found) {
|
|
4160
|
+
throw new ChildError('EXPORT_NOT_FOUND', `${name ? `export "${name}" is not actor logic` : 'no actor logic export'} in ${file}; exports: ${names.join(', ') || '(none)'}`);
|
|
4161
|
+
}
|
|
4162
|
+
const notes = [];
|
|
4163
|
+
const logicNames = names.filter(key => isLogic(mod[key]));
|
|
4164
|
+
if (!name && new Set(logicNames.map(key => mod[key])).size > 1) notes.push(`module exports ${logicNames.length} machines: ${logicNames.join(', ')}; using ${found} (pass --export to choose)`);
|
|
4165
|
+
return [found, mod[found], notes];
|
|
4166
|
+
}
|
|
4167
|
+
const flush = async () => {
|
|
4168
|
+
for (let i = 0; i < 3; i++) await new Promise(resolve => setImmediate(resolve));
|
|
4169
|
+
};
|
|
4170
|
+
async function recordTrace(xs, logic, sequence, recorder, stats, input, seed) {
|
|
4171
|
+
const clock = new xs.SimulatedClock();
|
|
4172
|
+
return withDeterminism(clock, seed, async () => {
|
|
4173
|
+
const actor = xs.createActor(logic, {
|
|
4174
|
+
clock,
|
|
4175
|
+
inspect: recorder.inspect,
|
|
4176
|
+
input
|
|
4177
|
+
});
|
|
4178
|
+
recorder.setRoot(actor);
|
|
4179
|
+
actor.subscribe({
|
|
4180
|
+
error: () => {}
|
|
4181
|
+
});
|
|
4182
|
+
actor.on('*', recorder.emit);
|
|
4183
|
+
const capture = input => {
|
|
4184
|
+
const snapshot = actor.getSnapshot();
|
|
4185
|
+
const children = Object.values(snapshot.children ?? {}).filter(Boolean).map(ref => ({
|
|
4186
|
+
id: normalizeId(ref.id),
|
|
4187
|
+
src: recorder.srcOf(ref)
|
|
4188
|
+
})).sort((a, b) => (a.id + a.src).localeCompare(b.id + b.src));
|
|
4189
|
+
const output = toJson(snapshot.output, stats);
|
|
4190
|
+
return {
|
|
4191
|
+
input,
|
|
4192
|
+
value: toJson(snapshot.value, stats),
|
|
4193
|
+
context: toJson(snapshot.context, stats) ?? null,
|
|
4194
|
+
status: snapshot.status,
|
|
4195
|
+
...(output !== undefined && {
|
|
4196
|
+
output
|
|
4197
|
+
}),
|
|
4198
|
+
...(snapshot.error !== undefined && {
|
|
4199
|
+
error: String(snapshot.error)
|
|
4200
|
+
}),
|
|
4201
|
+
...recorder.take(),
|
|
4202
|
+
children
|
|
4203
|
+
};
|
|
4204
|
+
};
|
|
4205
|
+
actor.start();
|
|
4206
|
+
await flush();
|
|
4207
|
+
const steps = [capture({
|
|
4208
|
+
start: true
|
|
4209
|
+
})];
|
|
4210
|
+
for (const step of sequence.steps) {
|
|
4211
|
+
if ('event' in step) actor.send(step.event);else clock.increment(step.advance);
|
|
4212
|
+
await flush();
|
|
4213
|
+
steps.push(capture(step));
|
|
4214
|
+
}
|
|
4215
|
+
actor.stop();
|
|
4216
|
+
return {
|
|
4217
|
+
sequenceId: sequence.id,
|
|
4218
|
+
steps
|
|
4219
|
+
};
|
|
4220
|
+
});
|
|
4221
|
+
}
|
|
4222
|
+
async function runJob(job) {
|
|
4223
|
+
const major = process.env.XSTATE_VERIFY_MAJOR === '5' ? 5 : 6;
|
|
4224
|
+
// The alias hook maps `xstate` to the selected major for these imports too.
|
|
4225
|
+
const xs = await import('xstate');
|
|
4226
|
+
const xstateVersion = createRequire(import.meta.url)('xstate/package.json').version;
|
|
4227
|
+
const [exportName, logic, notes] = await loadLogic(job.file, job.export);
|
|
4228
|
+
const machine = applyStubs(xs, major, logic, job.stubs);
|
|
4229
|
+
const stats = {
|
|
4230
|
+
nonSerializable: 0
|
|
4231
|
+
};
|
|
4232
|
+
let sequences = job.sequences ?? [];
|
|
4233
|
+
let eventsWithoutCases = [];
|
|
4234
|
+
let generationErrors = [];
|
|
4235
|
+
if (job.mode === 'baseline') {
|
|
4236
|
+
const graph = await import('xstate/graph');
|
|
4237
|
+
const candidates = eventCandidates(machine, job.events);
|
|
4238
|
+
eventsWithoutCases = candidates.withoutCases;
|
|
4239
|
+
const generated = await generateSequences(xs, graph, machine, candidates.events, job.options, job.input);
|
|
4240
|
+
sequences = generated.sequences;
|
|
4241
|
+
generationErrors = generated.generationErrors;
|
|
4242
|
+
}
|
|
4243
|
+
const traces = [];
|
|
4244
|
+
const unstubbed = new Set();
|
|
4245
|
+
for (const [index, sequence] of sequences.entries()) {
|
|
4246
|
+
const recorder = major === 5 ? createRecorder5(stats) : createRecorder6(stats);
|
|
4247
|
+
const trace = await recordTrace(xs, machine, sequence, recorder, stats, job.input, job.options.seed + index);
|
|
4248
|
+
for (const step of trace.steps) {
|
|
4249
|
+
for (const child of step.children) if (child.src === '(inline)') unstubbed.add(child.id);
|
|
4250
|
+
}
|
|
4251
|
+
traces.push(trace);
|
|
4252
|
+
}
|
|
4253
|
+
return {
|
|
4254
|
+
ok: true,
|
|
4255
|
+
export: exportName,
|
|
4256
|
+
xstateVersion,
|
|
4257
|
+
sequences,
|
|
4258
|
+
traces,
|
|
4259
|
+
unstubbed: [...unstubbed].sort(),
|
|
4260
|
+
eventsWithoutCases,
|
|
4261
|
+
generationErrors,
|
|
4262
|
+
nonSerializable: stats.nonSerializable,
|
|
4263
|
+
notes
|
|
4264
|
+
};
|
|
4265
|
+
}
|
|
4266
|
+
async function readStdin() {
|
|
4267
|
+
let data = '';
|
|
4268
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
4269
|
+
return data;
|
|
4270
|
+
}
|
|
4271
|
+
async function childMain() {
|
|
4272
|
+
let result;
|
|
4273
|
+
try {
|
|
4274
|
+
result = await runJob(JSON.parse(await readStdin()));
|
|
4275
|
+
} catch (err) {
|
|
4276
|
+
result = {
|
|
4277
|
+
ok: false,
|
|
4278
|
+
code: err instanceof ChildError ? err.code : 'RUN_ERROR',
|
|
4279
|
+
message: String(err?.message ?? err),
|
|
4280
|
+
stack: err?.stack
|
|
4281
|
+
};
|
|
4282
|
+
}
|
|
4283
|
+
process.stdout.write(RESULT_MARKER + JSON.stringify(result), () => process.exit(0));
|
|
4284
|
+
}
|
|
4285
|
+
|
|
4286
|
+
const require = createRequire(import.meta.url);
|
|
4287
|
+
// Resolved from this module, not cwd: the same lookup works from src and dist.
|
|
4288
|
+
const packageRoot = dirname(require.resolve('@xstate/codemod/package.json'));
|
|
4289
|
+
const DEFAULT_OPTIONS = {
|
|
4290
|
+
seed: 1,
|
|
4291
|
+
walks: 25,
|
|
4292
|
+
steps: 12,
|
|
4293
|
+
maxSequences: 200
|
|
4294
|
+
};
|
|
4295
|
+
const DEFAULT_TIMEOUT = 60_000;
|
|
4296
|
+
class VerifyError extends Error {
|
|
4297
|
+
constructor(phase, code, message, stderr, childStack) {
|
|
4298
|
+
super(message);
|
|
4299
|
+
this.phase = void 0;
|
|
4300
|
+
this.code = void 0;
|
|
4301
|
+
this.stderr = void 0;
|
|
4302
|
+
this.childStack = void 0;
|
|
4303
|
+
this.phase = phase;
|
|
4304
|
+
this.code = code;
|
|
4305
|
+
this.stderr = stderr;
|
|
4306
|
+
this.childStack = childStack;
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
async function runChild(major, job, timeout = DEFAULT_TIMEOUT) {
|
|
4310
|
+
const args = [
|
|
4311
|
+
// Select xstate's `development` export condition so v6 config diagnostics
|
|
4312
|
+
// throw at load with a clear message instead of failing later at runtime.
|
|
4313
|
+
'--conditions=development', '--import', pathToFileURL(require.resolve('tsx')).href, '--import', pathToFileURL(join(packageRoot, 'bin/alias-hook.mjs')).href, join(packageRoot, 'bin/verify-child.mjs')];
|
|
4314
|
+
return new Promise(done => {
|
|
4315
|
+
const child = spawn(process.execPath, args, {
|
|
4316
|
+
cwd: dirname(job.file),
|
|
4317
|
+
env: {
|
|
4318
|
+
...process.env,
|
|
4319
|
+
XSTATE_VERIFY_MAJOR: String(major)
|
|
4320
|
+
},
|
|
4321
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
4322
|
+
});
|
|
4323
|
+
let stdout = '';
|
|
4324
|
+
let stderr = '';
|
|
4325
|
+
let timedOut = false;
|
|
4326
|
+
const timer = setTimeout(() => {
|
|
4327
|
+
timedOut = true;
|
|
4328
|
+
child.kill('SIGKILL');
|
|
4329
|
+
}, timeout);
|
|
4330
|
+
child.stdout.setEncoding('utf8').on('data', chunk => stdout += chunk);
|
|
4331
|
+
child.stderr.setEncoding('utf8').on('data', chunk => stderr += chunk);
|
|
4332
|
+
child.on('close', code => {
|
|
4333
|
+
clearTimeout(timer);
|
|
4334
|
+
const at = stdout.lastIndexOf(RESULT_MARKER);
|
|
4335
|
+
let result;
|
|
4336
|
+
if (timedOut) {
|
|
4337
|
+
result = {
|
|
4338
|
+
ok: false,
|
|
4339
|
+
code: 'CHILD_TIMEOUT',
|
|
4340
|
+
message: `child timed out after ${timeout}ms`
|
|
4341
|
+
};
|
|
4342
|
+
} else if (at === -1) {
|
|
4343
|
+
// Node prints the fatal reason (e.g. heap out of memory) before the
|
|
4344
|
+
// native stack, so the first such line is the useful one.
|
|
4345
|
+
const fatal = stderr.split('\n').find(line => /FATAL ERROR|^\w*Error:/.test(line));
|
|
4346
|
+
result = {
|
|
4347
|
+
ok: false,
|
|
4348
|
+
code: 'CHILD_CRASH',
|
|
4349
|
+
message: `child exited with code ${code} without a result${fatal ? `: ${fatal.trim()}` : ''}`
|
|
4350
|
+
};
|
|
4351
|
+
} else {
|
|
4352
|
+
result = JSON.parse(stdout.slice(at + RESULT_MARKER.length));
|
|
4353
|
+
}
|
|
4354
|
+
done({
|
|
4355
|
+
result,
|
|
4356
|
+
stderr
|
|
4357
|
+
});
|
|
4358
|
+
});
|
|
4359
|
+
child.stdin.end(JSON.stringify(job));
|
|
4360
|
+
});
|
|
4361
|
+
}
|
|
4362
|
+
/** The error of the first trace's start step, when the machine errored there. */
|
|
4363
|
+
function startError(baseline) {
|
|
4364
|
+
const start = baseline.traces[0]?.steps[0];
|
|
4365
|
+
return start?.status === 'error' ? start.error ?? 'unknown error' : undefined;
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
/** Records a golden trace of a v5 module. Throws {@link VerifyError}. */
|
|
4369
|
+
async function runBaseline(options) {
|
|
4370
|
+
const file = resolve(options.file);
|
|
4371
|
+
const generate = {
|
|
4372
|
+
seed: options.seed ?? DEFAULT_OPTIONS.seed,
|
|
4373
|
+
walks: options.walks ?? DEFAULT_OPTIONS.walks,
|
|
4374
|
+
steps: options.steps ?? DEFAULT_OPTIONS.steps,
|
|
4375
|
+
maxSequences: options.maxSequences ?? DEFAULT_OPTIONS.maxSequences,
|
|
4376
|
+
...(options.simplePaths === false && {
|
|
4377
|
+
simplePaths: false
|
|
4378
|
+
})
|
|
4379
|
+
};
|
|
4380
|
+
const events = options.events ?? {};
|
|
4381
|
+
const stubs = options.stubs ?? {};
|
|
4382
|
+
const {
|
|
4383
|
+
result,
|
|
4384
|
+
stderr
|
|
4385
|
+
} = await runChild(5, {
|
|
4386
|
+
mode: 'baseline',
|
|
4387
|
+
file,
|
|
4388
|
+
export: options.export,
|
|
4389
|
+
events,
|
|
4390
|
+
stubs,
|
|
4391
|
+
input: options.input,
|
|
4392
|
+
options: generate
|
|
4393
|
+
}, options.timeout);
|
|
4394
|
+
if (!result.ok) throw new VerifyError('baseline', result.code, result.code === 'CHILD_TIMEOUT' ? `${result.message}; ${generate.simplePaths === false ? '' : 'pass --no-simple-paths or '}pass a smaller --events set` : result.message, stderr, result.stack);
|
|
4395
|
+
return {
|
|
4396
|
+
version: 1,
|
|
4397
|
+
file,
|
|
4398
|
+
export: result.export,
|
|
4399
|
+
xstateVersion: result.xstateVersion,
|
|
4400
|
+
options: generate,
|
|
4401
|
+
events,
|
|
4402
|
+
stubs,
|
|
4403
|
+
input: options.input,
|
|
4404
|
+
sequences: result.sequences,
|
|
4405
|
+
traces: result.traces,
|
|
4406
|
+
unstubbed: result.unstubbed,
|
|
4407
|
+
eventsWithoutCases: result.eventsWithoutCases,
|
|
4408
|
+
generationErrors: result.generationErrors ?? [],
|
|
4409
|
+
nonSerializable: result.nonSerializable,
|
|
4410
|
+
notes: result.notes ?? []
|
|
4411
|
+
};
|
|
4412
|
+
}
|
|
4413
|
+
/** Replays the baseline's sequences on a v6 module and diffs the traces. */
|
|
4414
|
+
async function runVerify(options) {
|
|
4415
|
+
const file = resolve(options.file);
|
|
4416
|
+
const report = {
|
|
4417
|
+
status: 'error',
|
|
4418
|
+
v5: {
|
|
4419
|
+
file: options.v5 ? resolve(options.v5) : '',
|
|
4420
|
+
export: '',
|
|
4421
|
+
xstateVersion: ''
|
|
4422
|
+
},
|
|
4423
|
+
v6: {
|
|
4424
|
+
file,
|
|
4425
|
+
export: '',
|
|
4426
|
+
xstateVersion: ''
|
|
4427
|
+
},
|
|
4428
|
+
sequences: 0,
|
|
4429
|
+
stepsCompared: 0,
|
|
4430
|
+
diffs: [],
|
|
4431
|
+
intentional: 0,
|
|
4432
|
+
unstubbed: [],
|
|
4433
|
+
eventsWithoutCases: [],
|
|
4434
|
+
generationErrors: [],
|
|
4435
|
+
nonSerializable: 0,
|
|
4436
|
+
notes: []
|
|
4437
|
+
};
|
|
4438
|
+
let baseline = options.baseline;
|
|
4439
|
+
if (!baseline) {
|
|
4440
|
+
if (!options.v5) throw new TypeError('runVerify needs `v5` or `baseline`');
|
|
4441
|
+
try {
|
|
4442
|
+
baseline = await runBaseline({
|
|
4443
|
+
...options,
|
|
4444
|
+
file: options.v5
|
|
4445
|
+
});
|
|
4446
|
+
} catch (err) {
|
|
4447
|
+
if (!(err instanceof VerifyError)) throw err;
|
|
4448
|
+
report.error = {
|
|
4449
|
+
phase: err.phase,
|
|
4450
|
+
code: err.code,
|
|
4451
|
+
message: err.message,
|
|
4452
|
+
stack: err.childStack,
|
|
4453
|
+
stderr: err.stderr
|
|
4454
|
+
};
|
|
4455
|
+
return report;
|
|
4456
|
+
}
|
|
4457
|
+
} else {
|
|
4458
|
+
if (options.events) report.notes.push('--events overrides the cases recorded in the baseline');
|
|
4459
|
+
if (options.stubs) report.notes.push('--stubs overrides the stubs recorded in the baseline');
|
|
4460
|
+
if (options.input !== undefined) report.notes.push('--input overrides the input recorded in the baseline');
|
|
4461
|
+
}
|
|
4462
|
+
report.v5 = {
|
|
4463
|
+
file: baseline.file,
|
|
4464
|
+
export: baseline.export,
|
|
4465
|
+
xstateVersion: baseline.xstateVersion
|
|
4466
|
+
};
|
|
4467
|
+
report.sequences = baseline.sequences.length;
|
|
4468
|
+
report.eventsWithoutCases = baseline.eventsWithoutCases;
|
|
4469
|
+
report.generationErrors = baseline.generationErrors ?? [];
|
|
4470
|
+
report.startError = startError(baseline);
|
|
4471
|
+
const {
|
|
4472
|
+
result,
|
|
4473
|
+
stderr
|
|
4474
|
+
} = await runChild(6, {
|
|
4475
|
+
mode: 'replay',
|
|
4476
|
+
file,
|
|
4477
|
+
export: options.export,
|
|
4478
|
+
events: options.events ?? baseline.events,
|
|
4479
|
+
stubs: options.stubs ?? baseline.stubs,
|
|
4480
|
+
input: options.input ?? baseline.input,
|
|
4481
|
+
options: baseline.options,
|
|
4482
|
+
sequences: baseline.sequences
|
|
4483
|
+
}, options.timeout);
|
|
4484
|
+
if (!result.ok) {
|
|
4485
|
+
report.error = {
|
|
4486
|
+
phase: 'replay',
|
|
4487
|
+
code: result.code,
|
|
4488
|
+
message: result.message,
|
|
4489
|
+
stack: result.stack,
|
|
4490
|
+
stderr
|
|
4491
|
+
};
|
|
4492
|
+
return report;
|
|
4493
|
+
}
|
|
4494
|
+
report.v6 = {
|
|
4495
|
+
file,
|
|
4496
|
+
export: result.export,
|
|
4497
|
+
xstateVersion: result.xstateVersion
|
|
4498
|
+
};
|
|
4499
|
+
report.unstubbed = [...new Set([...baseline.unstubbed, ...result.unstubbed])].sort();
|
|
4500
|
+
report.nonSerializable = baseline.nonSerializable + result.nonSerializable;
|
|
4501
|
+
for (const note of [...(baseline.notes ?? []), ...(result.notes ?? [])]) if (!report.notes.includes(note)) report.notes.push(note);
|
|
4502
|
+
const {
|
|
4503
|
+
diffs,
|
|
4504
|
+
stepsCompared
|
|
4505
|
+
} = diffTraces(baseline.sequences, baseline.traces, result.traces, options.compare ?? DEFAULT_FIELDS);
|
|
4506
|
+
report.diffs = diffs;
|
|
4507
|
+
report.stepsCompared = stepsCompared;
|
|
4508
|
+
report.intentional = diffs.filter(d => d.classification !== 'unclassified').length;
|
|
4509
|
+
report.status = diffs.length > report.intentional ? 'diverged' : 'equivalent';
|
|
4510
|
+
return report;
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
const USAGE = `Usage:
|
|
4514
|
+
xstate-codemod baseline <v5-file> [--out <path>] [options]
|
|
4515
|
+
xstate-codemod verify <v5-file> <v6-file> [options]
|
|
4516
|
+
xstate-codemod verify --baseline <path> <v6-file> [options]
|
|
4517
|
+
|
|
4518
|
+
Options:
|
|
4519
|
+
--export <name> export to load (default: default, else first actor logic)
|
|
4520
|
+
--events <file> event payload cases: { "TYPE": [payload, ...] }
|
|
4521
|
+
--stubs <file> actor/delay stubs: { "actors": {...}, "delays": {...} }
|
|
4522
|
+
--input <file> JSON passed as createActor(logic, { input })
|
|
4523
|
+
--seed <n> random walk seed (default 1)
|
|
4524
|
+
--walks <n> random walks (default 25)
|
|
4525
|
+
--steps <n> steps per walk (default 12)
|
|
4526
|
+
--max-sequences <n> sequence cap (default 200)
|
|
4527
|
+
--no-simple-paths skip the graph's simple paths (use when baseline times out)
|
|
4528
|
+
--compare <fields> fields to compare, or +field to add (e.g. +actions)
|
|
4529
|
+
--timeout <ms> per child process (default 60000)
|
|
4530
|
+
--json print the JSON report only
|
|
4531
|
+
`;
|
|
4532
|
+
const startLine = (error, hasInput) => ` start: machine errored at start: ${error} ${hasInput ? '(the baseline exercised nothing past start)' : '(pass --input if the machine reads input)'}`;
|
|
4533
|
+
const EVENTS_ADVICE = ' (add payload cases with --events)';
|
|
4534
|
+
|
|
4535
|
+
/** The --events advice applies only to a guard that threw on an event sent without a payload. */
|
|
4536
|
+
function generationLine(error, eventsWithoutCases) {
|
|
4537
|
+
const type = /^event '(.*)' skipped: guard threw:/.exec(error)?.[1];
|
|
4538
|
+
const advice = type === undefined ? /paths skipped: (?!graph traversal hit its limit)/.test(error) && eventsWithoutCases.length > 0 : eventsWithoutCases.includes(type);
|
|
4539
|
+
return ` generation: ${error}${advice ? EVENTS_ADVICE : ''}`;
|
|
4540
|
+
}
|
|
4541
|
+
const readJson = file => JSON.parse(readFileSync(file, 'utf8'));
|
|
4542
|
+
const show = value => JSON.stringify(value) ?? 'undefined';
|
|
4543
|
+
const showStep = step => 'event' in step ? `send ${show(step.event)}` : `advance ${step.advance}ms`;
|
|
4544
|
+
function compareFields(value) {
|
|
4545
|
+
if (!value) return undefined;
|
|
4546
|
+
const fields = value.split(',').map(f => f.trim()).filter(Boolean);
|
|
4547
|
+
const added = fields.filter(f => f.startsWith('+')).map(f => f.slice(1));
|
|
4548
|
+
const set = fields.filter(f => !f.startsWith('+'));
|
|
4549
|
+
return [...(set.length ? set : DEFAULT_FIELDS), ...added];
|
|
4550
|
+
}
|
|
4551
|
+
function formatReport(report, hasInput) {
|
|
4552
|
+
const lines = [`verify: ${report.status}`];
|
|
4553
|
+
lines.push(` v5: ${report.v5.file} (export ${report.v5.export || '?'}, xstate ${report.v5.xstateVersion || '?'})`);
|
|
4554
|
+
lines.push(` v6: ${report.v6.file} (export ${report.v6.export || '?'}, xstate ${report.v6.xstateVersion || '?'})`);
|
|
4555
|
+
if (report.error) {
|
|
4556
|
+
lines.push(` error (${report.error.phase}): ${report.error.code}: ${report.error.message}`);
|
|
4557
|
+
if (report.error.stderr.trim()) lines.push(` stderr:\n${report.error.stderr.trimEnd()}`);
|
|
4558
|
+
return lines.join('\n') + '\n';
|
|
4559
|
+
}
|
|
4560
|
+
const unclassified = report.diffs.filter(d => d.classification === 'unclassified');
|
|
4561
|
+
lines.push(` sequences: ${report.sequences}, steps compared: ${report.stepsCompared}, unclassified diffs: ${unclassified.length}, intentional: ${report.intentional}`);
|
|
4562
|
+
if (report.startError) lines.push(startLine(report.startError, hasInput));
|
|
4563
|
+
if (report.unstubbed.length) lines.push(` unstubbed inline actors: ${report.unstubbed.join(', ')}`);
|
|
4564
|
+
if (report.eventsWithoutCases.length) lines.push(` events sent without payload (add cases with --events if guards or actions read the payload): ${report.eventsWithoutCases.join(', ')}`);
|
|
4565
|
+
for (const error of report.generationErrors) lines.push(generationLine(error, report.eventsWithoutCases));
|
|
4566
|
+
if (report.nonSerializable) lines.push(` non-serializable values: ${report.nonSerializable}`);
|
|
4567
|
+
for (const note of report.notes) lines.push(` note: ${note}`);
|
|
4568
|
+
unclassified.slice(0, 5).forEach((diff, i) => {
|
|
4569
|
+
lines.push('', `Diff ${i + 1}: ${diff.sequenceId} (${diff.origin}) step ${diff.step}, field ${diff.field}`);
|
|
4570
|
+
lines.push(' witness:');
|
|
4571
|
+
if (!diff.witness.length) lines.push(' (start)');
|
|
4572
|
+
diff.witness.forEach((step, j) => lines.push(` ${j + 1}. ${showStep(step)}`));
|
|
4573
|
+
lines.push(` expected (v5): ${show(diff.expected)}`);
|
|
4574
|
+
lines.push(` actual (v6): ${show(diff.actual)}`);
|
|
4575
|
+
if (diff.detail) lines.push(` detail: ${diff.detail}`);
|
|
4576
|
+
});
|
|
4577
|
+
return lines.join('\n') + '\n';
|
|
4578
|
+
}
|
|
4579
|
+
|
|
4580
|
+
/** `baseline` and `verify` commands; `argv[0]` is the command name. */
|
|
4581
|
+
async function verifyCli(argv) {
|
|
4582
|
+
const [command, ...rest] = argv;
|
|
4583
|
+
let parsed;
|
|
4584
|
+
try {
|
|
4585
|
+
parsed = parseArgs$1({
|
|
4586
|
+
args: rest,
|
|
4587
|
+
allowPositionals: true,
|
|
4588
|
+
options: {
|
|
4589
|
+
out: {
|
|
4590
|
+
type: 'string'
|
|
4591
|
+
},
|
|
4592
|
+
baseline: {
|
|
4593
|
+
type: 'string'
|
|
4594
|
+
},
|
|
4595
|
+
export: {
|
|
4596
|
+
type: 'string'
|
|
4597
|
+
},
|
|
4598
|
+
events: {
|
|
4599
|
+
type: 'string'
|
|
4600
|
+
},
|
|
4601
|
+
stubs: {
|
|
4602
|
+
type: 'string'
|
|
4603
|
+
},
|
|
4604
|
+
input: {
|
|
4605
|
+
type: 'string'
|
|
4606
|
+
},
|
|
4607
|
+
seed: {
|
|
4608
|
+
type: 'string'
|
|
4609
|
+
},
|
|
4610
|
+
walks: {
|
|
4611
|
+
type: 'string'
|
|
4612
|
+
},
|
|
4613
|
+
steps: {
|
|
4614
|
+
type: 'string'
|
|
4615
|
+
},
|
|
4616
|
+
'max-sequences': {
|
|
4617
|
+
type: 'string'
|
|
4618
|
+
},
|
|
4619
|
+
'no-simple-paths': {
|
|
4620
|
+
type: 'boolean'
|
|
4621
|
+
},
|
|
4622
|
+
compare: {
|
|
4623
|
+
type: 'string'
|
|
4624
|
+
},
|
|
4625
|
+
timeout: {
|
|
4626
|
+
type: 'string'
|
|
4627
|
+
},
|
|
4628
|
+
json: {
|
|
4629
|
+
type: 'boolean'
|
|
4630
|
+
},
|
|
4631
|
+
help: {
|
|
4632
|
+
type: 'boolean',
|
|
4633
|
+
short: 'h'
|
|
4634
|
+
}
|
|
4635
|
+
}
|
|
4636
|
+
});
|
|
4637
|
+
} catch (err) {
|
|
4638
|
+
process.stderr.write(`${err.message}\n${USAGE}`);
|
|
4639
|
+
return 1;
|
|
4640
|
+
}
|
|
4641
|
+
if (parsed.values.help) {
|
|
4642
|
+
process.stdout.write(USAGE);
|
|
4643
|
+
return 0;
|
|
4644
|
+
}
|
|
4645
|
+
const {
|
|
4646
|
+
values,
|
|
4647
|
+
positionals
|
|
4648
|
+
} = parsed;
|
|
4649
|
+
const int = value => value === undefined ? undefined : Number.parseInt(value, 10);
|
|
4650
|
+
const common = {
|
|
4651
|
+
export: values.export,
|
|
4652
|
+
seed: int(values.seed),
|
|
4653
|
+
walks: int(values.walks),
|
|
4654
|
+
steps: int(values.steps),
|
|
4655
|
+
maxSequences: int(values['max-sequences']),
|
|
4656
|
+
...(values['no-simple-paths'] && {
|
|
4657
|
+
simplePaths: false
|
|
4658
|
+
}),
|
|
4659
|
+
timeout: int(values.timeout)
|
|
4660
|
+
};
|
|
4661
|
+
const numbers = [common.seed, common.walks, common.steps, common.maxSequences, common.timeout];
|
|
4662
|
+
if (numbers.some(n => n !== undefined && !Number.isFinite(n))) {
|
|
4663
|
+
process.stderr.write(`Numeric options must be integers\n${USAGE}`);
|
|
4664
|
+
return 1;
|
|
4665
|
+
}
|
|
4666
|
+
let events, stubs, input;
|
|
4667
|
+
try {
|
|
4668
|
+
events = values.events ? readJson(values.events) : undefined;
|
|
4669
|
+
stubs = values.stubs ? readJson(values.stubs) : undefined;
|
|
4670
|
+
input = values.input ? readJson(values.input) : undefined;
|
|
4671
|
+
} catch (err) {
|
|
4672
|
+
process.stderr.write(`${err.message}\n`);
|
|
4673
|
+
return 1;
|
|
4674
|
+
}
|
|
4675
|
+
if (command === 'baseline') {
|
|
4676
|
+
if (positionals.length !== 1) {
|
|
4677
|
+
process.stderr.write(USAGE);
|
|
4678
|
+
return 1;
|
|
4679
|
+
}
|
|
4680
|
+
let baseline;
|
|
4681
|
+
try {
|
|
4682
|
+
baseline = await runBaseline({
|
|
4683
|
+
...common,
|
|
4684
|
+
file: positionals[0],
|
|
4685
|
+
events,
|
|
4686
|
+
stubs,
|
|
4687
|
+
input
|
|
4688
|
+
});
|
|
4689
|
+
} catch (err) {
|
|
4690
|
+
if (!(err instanceof VerifyError)) throw err;
|
|
4691
|
+
process.stderr.write(`baseline: ${err.code}: ${err.message}\n${err.stderr}`);
|
|
4692
|
+
return 4;
|
|
4693
|
+
}
|
|
4694
|
+
const out = values.out ?? join('.xstate-migrate', `${baseline.export}.baseline.json`);
|
|
4695
|
+
mkdirSync(dirname(out), {
|
|
4696
|
+
recursive: true
|
|
4697
|
+
});
|
|
4698
|
+
writeFileSync(out, JSON.stringify(baseline, null, 2) + '\n');
|
|
4699
|
+
const error = startError(baseline);
|
|
4700
|
+
if (values.json) process.stdout.write(JSON.stringify({
|
|
4701
|
+
out,
|
|
4702
|
+
sequences: baseline.sequences.length,
|
|
4703
|
+
startError: error
|
|
4704
|
+
}) + '\n');else process.stdout.write(`baseline: ${baseline.sequences.length} sequences from ${baseline.file} (export ${baseline.export}) → ${out}\n` + (error ? startLine(error, input !== undefined) + '\n' : '') + baseline.generationErrors.map(e => generationLine(e, baseline.eventsWithoutCases) + '\n').join('') + (baseline.notes ?? []).map(note => ` note: ${note}\n`).join(''));
|
|
4705
|
+
return 0;
|
|
4706
|
+
}
|
|
4707
|
+
if (command === 'verify') {
|
|
4708
|
+
const expected = values.baseline ? 1 : 2;
|
|
4709
|
+
if (positionals.length !== expected) {
|
|
4710
|
+
process.stderr.write(USAGE);
|
|
4711
|
+
return 1;
|
|
4712
|
+
}
|
|
4713
|
+
let baseline;
|
|
4714
|
+
try {
|
|
4715
|
+
baseline = values.baseline ? readJson(values.baseline) : undefined;
|
|
4716
|
+
} catch (err) {
|
|
4717
|
+
process.stderr.write(`${err.message}\n`);
|
|
4718
|
+
return 1;
|
|
4719
|
+
}
|
|
4720
|
+
const report = await runVerify({
|
|
4721
|
+
...common,
|
|
4722
|
+
baseline,
|
|
4723
|
+
v5: baseline ? undefined : positionals[0],
|
|
4724
|
+
file: positionals[expected - 1],
|
|
4725
|
+
events,
|
|
4726
|
+
stubs,
|
|
4727
|
+
input,
|
|
4728
|
+
compare: compareFields(values.compare)
|
|
4729
|
+
});
|
|
4730
|
+
process.stdout.write(values.json ? JSON.stringify(report, null, 2) + '\n' : formatReport(report, (input ?? baseline?.input) !== undefined));
|
|
4731
|
+
return report.status === 'equivalent' ? 0 : report.status === 'diverged' ? 3 : 4;
|
|
4732
|
+
}
|
|
4733
|
+
process.stderr.write(USAGE);
|
|
4734
|
+
return 1;
|
|
867
4735
|
}
|
|
868
4736
|
|
|
869
|
-
export { applyToSourceFile, main, parseArgs, renameImports, reportIdPathTargets, reportRemovedApis, resolveFiles, run, selectTransforms, stringTargets, transforms, transformsByName, typesToSchemas };
|
|
4737
|
+
export { VerifyError, applyToSourceFile, childMain, lowerActions, main, parseArgs, renameImports, reportIdPathTargets, reportRemovedApis, resolveFiles, run, runBaseline, runVerify, selectTransforms, stringTargets, transforms, transformsByName, typesToSchemas, verifyCli };
|