@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.
- package/dist/lib/guardrails.js +71 -5
- package/package.json +8 -2
- package/src/lib/types.d.ts +3 -0
package/dist/lib/guardrails.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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:*",
|
package/src/lib/types.d.ts
CHANGED
|
@@ -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;
|