@signaltree/guardrails 5.0.2 → 5.0.5

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.
@@ -1,3 +1,5 @@
1
+ import { getPathNotifier } from '@signaltree/core';
2
+
1
3
  function isFunction(value) {
2
4
  return typeof value === 'function';
3
5
  }
@@ -33,6 +35,7 @@ function isDevEnvironment() {
33
35
  return true;
34
36
  }
35
37
  const MAX_TIMING_SAMPLES = 1000;
38
+ const RECOMPUTATION_WINDOW_MS = 1000;
36
39
  const POLLING_INTERVAL_MS = 50;
37
40
  function withGuardrails(config = {}) {
38
41
  return tree => {
@@ -53,11 +56,29 @@ function withGuardrails(config = {}) {
53
56
  hotPathData: new Map(),
54
57
  issueMap: new Map(),
55
58
  signalUsage: new Map(),
59
+ pathRecomputations: new Map(),
56
60
  memoryHistory: [],
57
61
  recomputationLog: [],
58
62
  previousState: tryStructuredClone(tree()),
59
63
  disposed: false
60
64
  };
65
+ tree['__devHooks'] = {
66
+ onRecompute: (path, count) => {
67
+ if (!context.disposed && !context.suppressed) {
68
+ recordRecomputations(path, context, count, Date.now());
69
+ const maxRecomputations = config.budgets?.maxRecomputations;
70
+ if (maxRecomputations && context.stats.recomputationCount > maxRecomputations) {
71
+ addIssue(context, {
72
+ type: 'budget',
73
+ severity: 'error',
74
+ message: `Recomputation budget exceeded: ${context.stats.recomputationCount} > ${maxRecomputations}`,
75
+ path,
76
+ count: 1
77
+ });
78
+ }
79
+ }
80
+ }
81
+ };
61
82
  const stopChangeDetection = startChangeDetection(context);
62
83
  const stopMonitoring = startMonitoring(context);
63
84
  const teardown = () => {
@@ -78,14 +99,44 @@ function withGuardrails(config = {}) {
78
99
  };
79
100
  }
80
101
  function startChangeDetection(context) {
102
+ if (!context.config.changeDetection?.disablePathNotifier) {
103
+ try {
104
+ const pathNotifier = getPathNotifier();
105
+ if (pathNotifier) {
106
+ const unsubscribe = pathNotifier.subscribe('**', (value, prev, path) => {
107
+ handlePathNotifierChange(context, path, value, prev);
108
+ });
109
+ return unsubscribe;
110
+ }
111
+ } catch {}
112
+ }
81
113
  try {
82
114
  const unsubscribe = context.tree.subscribe(() => {
83
115
  handleStateChange(context);
84
116
  });
85
117
  return unsubscribe;
86
- } catch {
87
- return startPollingChangeDetection(context);
88
- }
118
+ } catch {}
119
+ return startPollingChangeDetection(context);
120
+ }
121
+ function handlePathNotifierChange(context, path, newValue, oldValue) {
122
+ if (context.disposed || context.suppressed) return;
123
+ const startTime = performance.now();
124
+ const timestamp = Date.now();
125
+ const detail = {
126
+ path,
127
+ segments: path.split('.'),
128
+ oldValue,
129
+ newValue
130
+ };
131
+ analyzePreUpdate(context, detail, {});
132
+ const duration = performance.now() - startTime;
133
+ const diffRatio = calculateDiffRatio(oldValue, newValue);
134
+ analyzePostUpdate(context, detail, duration, diffRatio);
135
+ trackHotPath(context, path, duration);
136
+ trackSignalUsage(context, path, timestamp);
137
+ updateTimingStats(context, duration);
138
+ updateSignalStats(context, timestamp);
139
+ context.previousState = tryStructuredClone(context.tree());
89
140
  }
90
141
  function handleStateChange(context) {
91
142
  if (context.disposed || context.suppressed) return;
@@ -290,6 +341,20 @@ function updateSignalStats(context, timestamp) {
290
341
  const growth = baseline === 0 ? 0 : (signalCount - baseline) / Math.max(1, baseline);
291
342
  context.stats.memoryGrowthRate = growth;
292
343
  }
344
+ function recordRecomputations(path, context, count, timestamp) {
345
+ const currentPathCount = context.pathRecomputations.get(path) ?? 0;
346
+ context.pathRecomputations.set(path, currentPathCount + count);
347
+ if (count > 0) {
348
+ context.stats.recomputationCount += count;
349
+ for (let i = 0; i < count; i++) {
350
+ context.recomputationLog.push(timestamp);
351
+ }
352
+ }
353
+ if (context.recomputationLog.length) {
354
+ context.recomputationLog = context.recomputationLog.filter(value => timestamp - value <= RECOMPUTATION_WINDOW_MS);
355
+ }
356
+ context.stats.recomputationsPerSecond = context.recomputationLog.length;
357
+ }
293
358
  function updateHotPath(context, hotPath) {
294
359
  const existing = context.hotPaths.find(h => h.path === hotPath.path);
295
360
  if (existing) {
@@ -347,9 +412,10 @@ function addIssue(context, issue) {
347
412
  return;
348
413
  }
349
414
  context.issueMap.set(key, issue);
415
+ } else {
416
+ const key = `${issue.type}:${issue.path}:${issue.message}:${Date.now()}:${Math.random()}`;
417
+ context.issueMap.set(key, issue);
350
418
  }
351
- context.issues.push(issue);
352
- context.stats.violationCount++;
353
419
  if (context.config.mode === 'throw') {
354
420
  throw new Error(`[Guardrails] ${issue.message}`);
355
421
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/guardrails",
3
- "version": "5.0.2",
3
+ "version": "5.0.5",
4
4
  "description": "Development-only performance monitoring and anti-pattern detection for SignalTree",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -58,7 +58,13 @@
58
58
  },
59
59
  "peerDependencies": {
60
60
  "@signaltree/core": "^5.0.0",
61
- "tslib": "^2.0.0"
61
+ "tslib": "^2.0.0",
62
+ "vitest": "^2.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "vitest": {
66
+ "optional": true
67
+ }
62
68
  },
63
69
  "devDependencies": {
64
70
  "@signaltree/core": "workspace:*",
@@ -2,6 +2,9 @@ import type { SignalTree } from '@signaltree/core';
2
2
  export interface GuardrailsConfig<T extends Record<string, unknown> = Record<string, unknown>> {
3
3
  mode?: 'warn' | 'throw' | 'silent';
4
4
  enabled?: boolean | (() => boolean);
5
+ changeDetection?: {
6
+ disablePathNotifier?: boolean;
7
+ };
5
8
  budgets?: {
6
9
  maxUpdateTime?: number;
7
10
  maxMemory?: number;