@signaltree/guardrails 4.2.1 → 5.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.
@@ -1,9 +1,6 @@
1
1
  function isFunction(value) {
2
2
  return typeof value === 'function';
3
3
  }
4
- function isString(value) {
5
- return typeof value === 'string';
6
- }
7
4
  function isObjectLike(value) {
8
5
  return typeof value === 'object' && value !== null;
9
6
  }
@@ -20,10 +17,6 @@ function resolveEnabledFlag(option) {
20
17
  }
21
18
  return option;
22
19
  }
23
- function supportsMiddleware(tree) {
24
- const candidate = tree;
25
- return isFunction(candidate.addTap) && isFunction(candidate.removeTap);
26
- }
27
20
  function tryStructuredClone(value) {
28
21
  const cloneFn = globalThis.structuredClone;
29
22
  if (isFunction(cloneFn)) {
@@ -40,17 +33,13 @@ function isDevEnvironment() {
40
33
  return true;
41
34
  }
42
35
  const MAX_TIMING_SAMPLES = 1000;
43
- const RECOMPUTATION_WINDOW_MS = 1000;
36
+ const POLLING_INTERVAL_MS = 50;
44
37
  function withGuardrails(config = {}) {
45
38
  return tree => {
46
39
  const enabled = resolveEnabledFlag(config.enabled);
47
40
  if (!isDevEnvironment() || !enabled) {
48
41
  return tree;
49
42
  }
50
- if (!supportsMiddleware(tree)) {
51
- console.warn('[Guardrails] Tree does not expose middleware hooks; guardrails disabled.');
52
- return tree;
53
- }
54
43
  const stats = createRuntimeStats();
55
44
  const context = {
56
45
  tree,
@@ -66,20 +55,16 @@ function withGuardrails(config = {}) {
66
55
  signalUsage: new Map(),
67
56
  memoryHistory: [],
68
57
  recomputationLog: [],
58
+ previousState: tryStructuredClone(tree()),
69
59
  disposed: false
70
60
  };
71
- const middlewareId = `guardrails:${config.treeId ?? 'tree'}:${Math.random().toString(36).slice(2)}`;
72
- context.middlewareId = middlewareId;
73
- const middleware = createGuardrailsMiddleware(context);
74
- tree.addTap(middleware);
61
+ const stopChangeDetection = startChangeDetection(context);
75
62
  const stopMonitoring = startMonitoring(context);
76
63
  const teardown = () => {
77
64
  if (context.disposed) return;
78
65
  context.disposed = true;
66
+ stopChangeDetection();
79
67
  stopMonitoring();
80
- try {
81
- tree.removeTap(middlewareId);
82
- } catch {}
83
68
  };
84
69
  const originalDestroy = tree.destroy?.bind(tree);
85
70
  tree.destroy = () => {
@@ -92,6 +77,78 @@ function withGuardrails(config = {}) {
92
77
  return tree;
93
78
  };
94
79
  }
80
+ function startChangeDetection(context) {
81
+ try {
82
+ const unsubscribe = context.tree.subscribe(() => {
83
+ handleStateChange(context);
84
+ });
85
+ return unsubscribe;
86
+ } catch {
87
+ return startPollingChangeDetection(context);
88
+ }
89
+ }
90
+ function handleStateChange(context) {
91
+ if (context.disposed || context.suppressed) return;
92
+ const currentState = context.tree();
93
+ const previousState = context.previousState;
94
+ if (!previousState) {
95
+ context.previousState = tryStructuredClone(currentState);
96
+ return;
97
+ }
98
+ const currentJson = JSON.stringify(currentState);
99
+ const previousJson = JSON.stringify(previousState);
100
+ if (currentJson !== previousJson) {
101
+ const startTime = performance.now();
102
+ const timestamp = Date.now();
103
+ const changedPaths = detectChangedPaths(previousState, currentState);
104
+ for (const path of changedPaths) {
105
+ const detail = {
106
+ path,
107
+ segments: path.split('.'),
108
+ oldValue: getValueAtPath(previousState, path.split('.')),
109
+ newValue: getValueAtPath(currentState, path.split('.'))
110
+ };
111
+ analyzePreUpdate(context, detail, {});
112
+ const duration = performance.now() - startTime;
113
+ const diffRatio = calculateDiffRatio(detail.oldValue, detail.newValue);
114
+ analyzePostUpdate(context, detail, duration, diffRatio);
115
+ trackHotPath(context, path, duration);
116
+ trackSignalUsage(context, path, timestamp);
117
+ }
118
+ const totalDuration = performance.now() - startTime;
119
+ updateTimingStats(context, totalDuration);
120
+ updateSignalStats(context, timestamp);
121
+ context.previousState = tryStructuredClone(currentState);
122
+ }
123
+ }
124
+ function startPollingChangeDetection(context) {
125
+ const pollForChanges = () => {
126
+ handleStateChange(context);
127
+ };
128
+ context.pollingIntervalId = setInterval(pollForChanges, POLLING_INTERVAL_MS);
129
+ return () => {
130
+ if (context.pollingIntervalId) {
131
+ clearInterval(context.pollingIntervalId);
132
+ context.pollingIntervalId = undefined;
133
+ }
134
+ };
135
+ }
136
+ function detectChangedPaths(oldState, newState, prefix = '') {
137
+ const changes = [];
138
+ const allKeys = new Set([...Object.keys(oldState || {}), ...Object.keys(newState || {})]);
139
+ for (const key of allKeys) {
140
+ const path = prefix ? `${prefix}.${key}` : key;
141
+ const oldVal = oldState?.[key];
142
+ const newVal = newState?.[key];
143
+ if (oldVal === newVal) continue;
144
+ if (isObjectLike(oldVal) && isObjectLike(newVal) && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
145
+ changes.push(...detectChangedPaths(oldVal, newVal, path));
146
+ } else {
147
+ changes.push(path);
148
+ }
149
+ }
150
+ return changes;
151
+ }
95
152
  function createRuntimeStats() {
96
153
  return {
97
154
  updateCount: 0,
@@ -111,51 +168,6 @@ function createRuntimeStats() {
111
168
  violationCount: 0
112
169
  };
113
170
  }
114
- function createGuardrailsMiddleware(context) {
115
- return {
116
- id: context.middlewareId ?? 'guardrails',
117
- before: (action, payload, state) => {
118
- if (context.suppressed) {
119
- context.currentUpdate = null;
120
- return !context.disposed;
121
- }
122
- const metadata = extractMetadata(payload);
123
- if (shouldSuppressUpdate(context, metadata)) {
124
- context.currentUpdate = null;
125
- return !context.disposed;
126
- }
127
- const details = collectUpdateDetails(payload, state);
128
- context.currentUpdate = {
129
- action,
130
- startTime: performance.now(),
131
- metadata,
132
- details
133
- };
134
- for (const detail of details) {
135
- analyzePreUpdate(context, detail, metadata);
136
- }
137
- return !context.disposed;
138
- },
139
- after: (_action, _payload, _previousState, newState) => {
140
- const pending = context.currentUpdate;
141
- if (!pending) return;
142
- const duration = Math.max(0, performance.now() - pending.startTime);
143
- const timestamp = Date.now();
144
- const recomputations = Math.max(0, pending.details.length - 1);
145
- updateTimingStats(context, duration);
146
- for (const [index, detail] of pending.details.entries()) {
147
- const latest = getValueAtPath(newState, detail.segments);
148
- const diffRatio = calculateDiffRatio(detail.oldValue, latest);
149
- analyzePostUpdate(context, detail, duration, diffRatio, index === 0);
150
- trackHotPath(context, detail.path, duration);
151
- trackSignalUsage(context, detail.path, timestamp);
152
- }
153
- updateSignalStats(context, timestamp);
154
- recordRecomputations(context, recomputations, timestamp);
155
- context.currentUpdate = null;
156
- }
157
- };
158
- }
159
171
  function updatePercentiles(context) {
160
172
  if (context.timings.length === 0) return;
161
173
  const sorted = [...context.timings].sort((a, b) => a - b);
@@ -193,7 +205,7 @@ function analyzePreUpdate(context, detail, metadata) {
193
205
  }
194
206
  }
195
207
  function analyzePostUpdate(context, detail, duration, diffRatio, isPrimary) {
196
- if (isPrimary && context.config.budgets?.maxUpdateTime && duration > context.config.budgets.maxUpdateTime) {
208
+ if (context.config.budgets?.maxUpdateTime && duration > context.config.budgets.maxUpdateTime) {
197
209
  addIssue(context, {
198
210
  type: 'budget',
199
211
  severity: 'error',
@@ -278,18 +290,6 @@ function updateSignalStats(context, timestamp) {
278
290
  const growth = baseline === 0 ? 0 : (signalCount - baseline) / Math.max(1, baseline);
279
291
  context.stats.memoryGrowthRate = growth;
280
292
  }
281
- function recordRecomputations(context, count, timestamp) {
282
- if (count > 0) {
283
- context.stats.recomputationCount += count;
284
- for (let i = 0; i < count; i++) {
285
- context.recomputationLog.push(timestamp);
286
- }
287
- }
288
- if (context.recomputationLog.length) {
289
- context.recomputationLog = context.recomputationLog.filter(value => timestamp - value <= RECOMPUTATION_WINDOW_MS);
290
- }
291
- context.stats.recomputationsPerSecond = context.recomputationLog.length;
292
- }
293
293
  function updateHotPath(context, hotPath) {
294
294
  const existing = context.hotPaths.find(h => h.path === hotPath.path);
295
295
  if (existing) {
@@ -354,15 +354,6 @@ function addIssue(context, issue) {
354
354
  throw new Error(`[Guardrails] ${issue.message}`);
355
355
  }
356
356
  }
357
- function shouldSuppressUpdate(context, metadata) {
358
- if (context.suppressed) return true;
359
- if (!metadata) return false;
360
- if (metadata.suppressGuardrails && context.config.suppression?.respectMetadata !== false) {
361
- return true;
362
- }
363
- const autoSuppress = new Set(context.config.suppression?.autoSuppress ?? []);
364
- return [metadata.intent, metadata.source].some(value => isString(value) && autoSuppress.has(value));
365
- }
366
357
  function startMonitoring(context) {
367
358
  const interval = setInterval(() => {
368
359
  if (context.disposed) {
@@ -527,55 +518,6 @@ function createAPI(context, teardown) {
527
518
  }
528
519
  };
529
520
  }
530
- function extractMetadata(payload) {
531
- if (!isObjectLike(payload)) return undefined;
532
- const candidate = payload['metadata'];
533
- return isObjectLike(candidate) ? candidate : undefined;
534
- }
535
- function collectUpdateDetails(payload, stateSnapshot) {
536
- const details = [];
537
- const visit = (value, segments, currentState) => {
538
- const path = segments.length ? segments.join('.') : 'root';
539
- const oldValue = captureValue(currentState);
540
- if (isObjectLike(value)) {
541
- details.push({
542
- path,
543
- segments: [...segments],
544
- newValue: value,
545
- oldValue
546
- });
547
- for (const [key, child] of Object.entries(value)) {
548
- visit(child, [...segments, key], isObjectLike(currentState) ? currentState[key] : undefined);
549
- }
550
- return;
551
- }
552
- details.push({
553
- path,
554
- segments: [...segments],
555
- newValue: value,
556
- oldValue
557
- });
558
- };
559
- if (isObjectLike(payload)) {
560
- visit(payload, [], stateSnapshot);
561
- } else {
562
- details.push({
563
- path: 'root',
564
- segments: [],
565
- newValue: payload,
566
- oldValue: captureValue(stateSnapshot)
567
- });
568
- }
569
- if (details.length === 0) {
570
- details.push({
571
- path: 'root',
572
- segments: [],
573
- newValue: payload,
574
- oldValue: captureValue(stateSnapshot)
575
- });
576
- }
577
- return details;
578
- }
579
521
  function getValueAtPath(source, segments) {
580
522
  let current = source;
581
523
  for (const segment of segments) {
@@ -586,9 +528,6 @@ function getValueAtPath(source, segments) {
586
528
  }
587
529
  return current;
588
530
  }
589
- function captureValue(value) {
590
- return tryStructuredClone(value);
591
- }
592
531
  function isPlainObject(value) {
593
532
  if (!value || typeof value !== 'object') return false;
594
533
  const proto = Object.getPrototypeOf(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/guardrails",
3
- "version": "4.2.1",
3
+ "version": "5.0.0",
4
4
  "description": "Development-only performance monitoring and anti-pattern detection for SignalTree",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -57,7 +57,7 @@
57
57
  "type-check": "tsc --project tsconfig.lib.json --noEmit"
58
58
  },
59
59
  "peerDependencies": {
60
- "@signaltree/core": "^4.2.1",
60
+ "@signaltree/core": "^5.0.0",
61
61
  "tslib": "^2.0.0"
62
62
  },
63
63
  "devDependencies": {