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