@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.
- package/dist/lib/guardrails.js +77 -138
- package/package.json +2 -2
package/dist/lib/guardrails.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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": "
|
|
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": "^
|
|
60
|
+
"@signaltree/core": "^5.0.0",
|
|
61
61
|
"tslib": "^2.0.0"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|