state-surgeon 1.0.0 → 2.0.0
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/dist/dashboard/index.js +3444 -512
- package/dist/dashboard/index.js.map +1 -1
- package/dist/dashboard/index.mjs +3432 -500
- package/dist/dashboard/index.mjs.map +1 -1
- package/dist/index.js +323 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +321 -5
- package/dist/index.mjs.map +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/index.js.map +1 -1
- package/dist/recorder/index.mjs +1 -1
- package/dist/recorder/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { v4 } from 'uuid';
|
|
2
|
-
import express
|
|
2
|
+
import express from 'express';
|
|
3
3
|
import { WebSocketServer } from 'ws';
|
|
4
4
|
|
|
5
5
|
var __defProp = Object.defineProperty;
|
|
@@ -14,6 +14,320 @@ var __export = (target, all) => {
|
|
|
14
14
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
// src/core/analyzer.ts
|
|
18
|
+
var StateAnalyzer = class {
|
|
19
|
+
constructor() {
|
|
20
|
+
this.invariants = [];
|
|
21
|
+
this.updateTimestamps = /* @__PURE__ */ new Map();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Register custom invariant rules
|
|
25
|
+
*/
|
|
26
|
+
addInvariant(invariant) {
|
|
27
|
+
this.invariants.push(invariant);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Analyze a single mutation for issues
|
|
31
|
+
*/
|
|
32
|
+
analyzeMutation(mutation, index, timeline) {
|
|
33
|
+
const issues = [];
|
|
34
|
+
if (mutation.diff) {
|
|
35
|
+
for (const diff of mutation.diff) {
|
|
36
|
+
if (diff.operation === "REMOVE") {
|
|
37
|
+
issues.push({
|
|
38
|
+
id: `issue_${mutation.id}_${diff.path}_loss`,
|
|
39
|
+
category: "state-loss",
|
|
40
|
+
severity: "critical",
|
|
41
|
+
title: `State field removed: ${diff.path}`,
|
|
42
|
+
description: `The field "${diff.path}" was removed from state. This may indicate an overwrite instead of a merge.`,
|
|
43
|
+
mutationId: mutation.id,
|
|
44
|
+
mutationIndex: index,
|
|
45
|
+
path: diff.path,
|
|
46
|
+
previousValue: diff.oldValue,
|
|
47
|
+
currentValue: void 0,
|
|
48
|
+
suggestion: "Use spread operator to preserve existing fields: setState(prev => ({ ...prev, newField }))",
|
|
49
|
+
timestamp: mutation.timestamp
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (diff.operation !== "REMOVE") {
|
|
53
|
+
const invalidIssue = this.checkInvalidValue(diff, mutation, index);
|
|
54
|
+
if (invalidIssue) {
|
|
55
|
+
issues.push(invalidIssue);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (diff.operation === "UPDATE") {
|
|
59
|
+
const typeIssue = this.checkTypeChange(diff, mutation, index);
|
|
60
|
+
if (typeIssue) {
|
|
61
|
+
issues.push(typeIssue);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (this.isNoOpUpdate(mutation)) {
|
|
67
|
+
issues.push({
|
|
68
|
+
id: `issue_${mutation.id}_noop`,
|
|
69
|
+
category: "no-op-update",
|
|
70
|
+
severity: "info",
|
|
71
|
+
title: "Redundant state update",
|
|
72
|
+
description: "This mutation did not change any values. Consider memoizing or adding conditions.",
|
|
73
|
+
mutationId: mutation.id,
|
|
74
|
+
mutationIndex: index,
|
|
75
|
+
suggestion: "Add a condition before updating: if (newValue !== currentValue) setState(newValue)",
|
|
76
|
+
timestamp: mutation.timestamp
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const excessiveIssue = this.checkExcessiveUpdates(mutation, index);
|
|
80
|
+
if (excessiveIssue) {
|
|
81
|
+
issues.push(excessiveIssue);
|
|
82
|
+
}
|
|
83
|
+
for (const invariant of this.invariants) {
|
|
84
|
+
const violation = this.checkInvariant(invariant, mutation, index);
|
|
85
|
+
if (violation) {
|
|
86
|
+
issues.push(violation);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return issues;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Check for invalid values (NaN, unexpected undefined/null)
|
|
93
|
+
*/
|
|
94
|
+
checkInvalidValue(diff, mutation, index) {
|
|
95
|
+
const value = diff.newValue;
|
|
96
|
+
if (typeof value === "number" && isNaN(value)) {
|
|
97
|
+
return {
|
|
98
|
+
id: `issue_${mutation.id}_${diff.path}_nan`,
|
|
99
|
+
category: "invalid-value",
|
|
100
|
+
severity: "critical",
|
|
101
|
+
title: `NaN value at: ${diff.path}`,
|
|
102
|
+
description: `The value at "${diff.path}" became NaN. This usually indicates a calculation with undefined/null.`,
|
|
103
|
+
mutationId: mutation.id,
|
|
104
|
+
mutationIndex: index,
|
|
105
|
+
path: diff.path,
|
|
106
|
+
previousValue: diff.oldValue,
|
|
107
|
+
currentValue: value,
|
|
108
|
+
suggestion: "Check for undefined/null values before calculation. Use default values: (value ?? 0)",
|
|
109
|
+
timestamp: mutation.timestamp
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (value === void 0 && diff.oldValue !== void 0 && diff.operation === "UPDATE") {
|
|
113
|
+
return {
|
|
114
|
+
id: `issue_${mutation.id}_${diff.path}_undefined`,
|
|
115
|
+
category: "invalid-value",
|
|
116
|
+
severity: "critical",
|
|
117
|
+
title: `Value became undefined: ${diff.path}`,
|
|
118
|
+
description: `The field "${diff.path}" changed from a defined value to undefined.`,
|
|
119
|
+
mutationId: mutation.id,
|
|
120
|
+
mutationIndex: index,
|
|
121
|
+
path: diff.path,
|
|
122
|
+
previousValue: diff.oldValue,
|
|
123
|
+
currentValue: void 0,
|
|
124
|
+
suggestion: "Ensure the value is always defined or explicitly handle undefined cases.",
|
|
125
|
+
timestamp: mutation.timestamp
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Check for unexpected type changes
|
|
132
|
+
*/
|
|
133
|
+
checkTypeChange(diff, mutation, index) {
|
|
134
|
+
if (diff.oldValue === void 0 || diff.oldValue === null) return null;
|
|
135
|
+
if (diff.newValue === void 0 || diff.newValue === null) return null;
|
|
136
|
+
const oldType = typeof diff.oldValue;
|
|
137
|
+
const newType = typeof diff.newValue;
|
|
138
|
+
if (oldType !== newType) {
|
|
139
|
+
return {
|
|
140
|
+
id: `issue_${mutation.id}_${diff.path}_type`,
|
|
141
|
+
category: "type-change",
|
|
142
|
+
severity: "warning",
|
|
143
|
+
title: `Type changed: ${diff.path}`,
|
|
144
|
+
description: `The type of "${diff.path}" changed from ${oldType} to ${newType}.`,
|
|
145
|
+
mutationId: mutation.id,
|
|
146
|
+
mutationIndex: index,
|
|
147
|
+
path: diff.path,
|
|
148
|
+
previousValue: diff.oldValue,
|
|
149
|
+
currentValue: diff.newValue,
|
|
150
|
+
suggestion: "Ensure consistent types. Use TypeScript or runtime validation.",
|
|
151
|
+
timestamp: mutation.timestamp
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Check if mutation is a no-op (no actual changes)
|
|
158
|
+
*/
|
|
159
|
+
isNoOpUpdate(mutation) {
|
|
160
|
+
if (!mutation.diff || mutation.diff.length === 0) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return mutation.diff.every((d) => {
|
|
164
|
+
if (d.operation !== "UPDATE") return false;
|
|
165
|
+
return JSON.stringify(d.oldValue) === JSON.stringify(d.newValue);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Check for excessive updates in short time period
|
|
170
|
+
*/
|
|
171
|
+
checkExcessiveUpdates(mutation, index) {
|
|
172
|
+
const key = mutation.component || mutation.source;
|
|
173
|
+
const timestamps = this.updateTimestamps.get(key) || [];
|
|
174
|
+
timestamps.push(mutation.timestamp);
|
|
175
|
+
const cutoff = mutation.timestamp - 100;
|
|
176
|
+
const recentTimestamps = timestamps.filter((t) => t >= cutoff);
|
|
177
|
+
this.updateTimestamps.set(key, recentTimestamps);
|
|
178
|
+
if (recentTimestamps.length > 5) {
|
|
179
|
+
return {
|
|
180
|
+
id: `issue_${mutation.id}_excessive`,
|
|
181
|
+
category: "excessive-updates",
|
|
182
|
+
severity: "warning",
|
|
183
|
+
title: `Excessive updates from: ${key}`,
|
|
184
|
+
description: `${recentTimestamps.length} mutations in 100ms from "${key}". This may cause performance issues.`,
|
|
185
|
+
mutationId: mutation.id,
|
|
186
|
+
mutationIndex: index,
|
|
187
|
+
suggestion: "Use debouncing, batching, or memoization to reduce update frequency.",
|
|
188
|
+
timestamp: mutation.timestamp
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check custom invariant rule
|
|
195
|
+
*/
|
|
196
|
+
checkInvariant(invariant, mutation, index) {
|
|
197
|
+
if (!mutation.nextState) return null;
|
|
198
|
+
const value = getValueAtPath(mutation.nextState, invariant.path);
|
|
199
|
+
try {
|
|
200
|
+
const isValid = invariant.rule(value);
|
|
201
|
+
if (!isValid) {
|
|
202
|
+
return {
|
|
203
|
+
id: `issue_${mutation.id}_invariant_${invariant.name}`,
|
|
204
|
+
category: "broken-invariant",
|
|
205
|
+
severity: "critical",
|
|
206
|
+
title: `Invariant violated: ${invariant.name}`,
|
|
207
|
+
description: invariant.message,
|
|
208
|
+
mutationId: mutation.id,
|
|
209
|
+
mutationIndex: index,
|
|
210
|
+
path: invariant.path,
|
|
211
|
+
currentValue: value,
|
|
212
|
+
suggestion: `Ensure the invariant "${invariant.name}" is always maintained.`,
|
|
213
|
+
timestamp: mutation.timestamp
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Analyze entire timeline for issues
|
|
223
|
+
*/
|
|
224
|
+
analyzeTimeline(timeline) {
|
|
225
|
+
const allIssues = [];
|
|
226
|
+
this.updateTimestamps.clear();
|
|
227
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
228
|
+
const issues = this.analyzeMutation(timeline[i], i, timeline);
|
|
229
|
+
allIssues.push(...issues);
|
|
230
|
+
}
|
|
231
|
+
return allIssues;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Build dependency graph showing which components touch which state paths
|
|
235
|
+
*/
|
|
236
|
+
buildDependencyGraph(timeline) {
|
|
237
|
+
const nodes = /* @__PURE__ */ new Map();
|
|
238
|
+
for (const mutation of timeline) {
|
|
239
|
+
if (!mutation.diff) continue;
|
|
240
|
+
const component = mutation.component || mutation.source || "unknown";
|
|
241
|
+
for (const diff of mutation.diff) {
|
|
242
|
+
const existing = nodes.get(diff.path);
|
|
243
|
+
if (existing) {
|
|
244
|
+
existing.components.add(component);
|
|
245
|
+
existing.mutationCount++;
|
|
246
|
+
existing.lastMutationId = mutation.id;
|
|
247
|
+
} else {
|
|
248
|
+
nodes.set(diff.path, {
|
|
249
|
+
path: diff.path,
|
|
250
|
+
components: /* @__PURE__ */ new Set([component]),
|
|
251
|
+
mutationCount: 1,
|
|
252
|
+
lastMutationId: mutation.id
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const couplings = [];
|
|
258
|
+
for (const [path, node] of nodes) {
|
|
259
|
+
if (node.components.size > 1) {
|
|
260
|
+
couplings.push({
|
|
261
|
+
path,
|
|
262
|
+
components: Array.from(node.components),
|
|
263
|
+
severity: node.components.size > 2 ? "critical" : "warning"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return { nodes, couplings };
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Find causal chain - which mutations caused downstream effects
|
|
271
|
+
*/
|
|
272
|
+
findCausalChain(mutationId, timeline) {
|
|
273
|
+
const rootIndex = timeline.findIndex((m) => m.id === mutationId);
|
|
274
|
+
if (rootIndex === -1) return null;
|
|
275
|
+
const root = timeline[rootIndex];
|
|
276
|
+
const effects = [];
|
|
277
|
+
if (!root.diff) return { rootMutation: root, effects };
|
|
278
|
+
const changedPaths = new Set(root.diff.map((d) => d.path));
|
|
279
|
+
for (let i = rootIndex + 1; i < timeline.length; i++) {
|
|
280
|
+
const mutation = timeline[i];
|
|
281
|
+
if (!mutation.diff) continue;
|
|
282
|
+
for (const diff of mutation.diff) {
|
|
283
|
+
for (const changedPath of changedPaths) {
|
|
284
|
+
if (diff.path.startsWith(changedPath) || changedPath.startsWith(diff.path)) {
|
|
285
|
+
effects.push({
|
|
286
|
+
mutation,
|
|
287
|
+
causedBy: root.id,
|
|
288
|
+
reason: `Uses path "${diff.path}" which was affected by "${changedPath}"`
|
|
289
|
+
});
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { rootMutation: root, effects };
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Find the first mutation that corrupted state
|
|
299
|
+
* Uses binary search for efficiency
|
|
300
|
+
*/
|
|
301
|
+
findCorruptionPoint(timeline, validator) {
|
|
302
|
+
let left = 0;
|
|
303
|
+
let right = timeline.length - 1;
|
|
304
|
+
let result = null;
|
|
305
|
+
while (left <= right) {
|
|
306
|
+
const mid = Math.floor((left + right) / 2);
|
|
307
|
+
const mutation = timeline[mid];
|
|
308
|
+
if (mutation.nextState && !validator(mutation.nextState)) {
|
|
309
|
+
result = { mutation, index: mid };
|
|
310
|
+
right = mid - 1;
|
|
311
|
+
} else {
|
|
312
|
+
left = mid + 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
function getValueAtPath(obj, path) {
|
|
319
|
+
if (!path) return obj;
|
|
320
|
+
const parts = path.split(".");
|
|
321
|
+
let current = obj;
|
|
322
|
+
for (const part of parts) {
|
|
323
|
+
if (current === null || current === void 0) return void 0;
|
|
324
|
+
if (typeof current !== "object") return void 0;
|
|
325
|
+
current = current[part];
|
|
326
|
+
}
|
|
327
|
+
return current;
|
|
328
|
+
}
|
|
329
|
+
var analyzer = new StateAnalyzer();
|
|
330
|
+
|
|
17
331
|
// src/core/diff.ts
|
|
18
332
|
function deepClone(obj) {
|
|
19
333
|
if (obj === null || typeof obj !== "object") {
|
|
@@ -114,7 +428,7 @@ function applyDiff(state, diffs) {
|
|
|
114
428
|
}
|
|
115
429
|
return result;
|
|
116
430
|
}
|
|
117
|
-
function
|
|
431
|
+
function getValueAtPath2(obj, path) {
|
|
118
432
|
if (!path || path === "root") return obj;
|
|
119
433
|
const parts = parsePath(path);
|
|
120
434
|
let current = obj;
|
|
@@ -1056,6 +1370,8 @@ __export(recorder_exports, {
|
|
|
1056
1370
|
createAPIRoutes: () => createAPIRoutes,
|
|
1057
1371
|
createRecorderServer: () => createRecorderServer
|
|
1058
1372
|
});
|
|
1373
|
+
|
|
1374
|
+
// src/recorder/api.ts
|
|
1059
1375
|
function createAPIRoutes(store, reconstructor) {
|
|
1060
1376
|
const router = Router();
|
|
1061
1377
|
router.get("/health", (_req, res) => {
|
|
@@ -1306,8 +1622,8 @@ var TimelineReconstructor = class {
|
|
|
1306
1622
|
}
|
|
1307
1623
|
}
|
|
1308
1624
|
} else {
|
|
1309
|
-
const prevValue =
|
|
1310
|
-
const nextValue =
|
|
1625
|
+
const prevValue = getValueAtPath2(mutation.previousState, path);
|
|
1626
|
+
const nextValue = getValueAtPath2(mutation.nextState, path);
|
|
1311
1627
|
if (prevValue !== nextValue) {
|
|
1312
1628
|
result.push(mutation);
|
|
1313
1629
|
}
|
|
@@ -1793,6 +2109,6 @@ function createRecorderServer(options = {}) {
|
|
|
1793
2109
|
return server;
|
|
1794
2110
|
}
|
|
1795
2111
|
|
|
1796
|
-
export { applyDiff, calculateDiff, compress, createMutation, debounce, decompress, deepClone, deepEqual, deepMerge, formatBytes, formatDuration, generateSessionId, getLogicalClock, getValueAtPath, instrument_exports as instrument, isBrowser, isNode, parseCallStack, recorder_exports as recorder, resetLogicalClock, safeParse, safeStringify, setValueAtPath, simpleHash, throttle };
|
|
2112
|
+
export { StateAnalyzer, analyzer, applyDiff, calculateDiff, compress, createMutation, debounce, decompress, deepClone, deepEqual, deepMerge, formatBytes, formatDuration, generateSessionId, getLogicalClock, getValueAtPath2 as getValueAtPath, instrument_exports as instrument, isBrowser, isNode, parseCallStack, recorder_exports as recorder, resetLogicalClock, safeParse, safeStringify, setValueAtPath, simpleHash, throttle };
|
|
1797
2113
|
//# sourceMappingURL=index.mjs.map
|
|
1798
2114
|
//# sourceMappingURL=index.mjs.map
|