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/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 getValueAtPath(obj, path) {
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 = express.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 = getValueAtPath(mutation.previousState, path);
1316
- const nextValue = getValueAtPath(mutation.nextState, path);
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 = getValueAtPath;
2133
+ exports.getValueAtPath = getValueAtPath2;
1816
2134
  exports.instrument = instrument_exports;
1817
2135
  exports.isBrowser = isBrowser;
1818
2136
  exports.isNode = isNode;