@signaltree/guardrails 6.0.5 → 6.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signaltree/guardrails",
3
- "version": "6.0.5",
3
+ "version": "6.0.9",
4
4
  "description": "Development-only performance monitoring and anti-pattern detection for SignalTree",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -1,119 +0,0 @@
1
- import { rules, guardrails } from '../noop.js';
2
-
3
- function isGuardrailsConfig(value) {
4
- return Boolean(value) && typeof value === 'object';
5
- }
6
- function resolveGuardrailsConfig(guardrails) {
7
- if (guardrails === false) {
8
- return undefined;
9
- }
10
- if (isGuardrailsConfig(guardrails)) {
11
- return guardrails;
12
- }
13
- return {
14
- budgets: {
15
- maxUpdateTime: 16,
16
- maxRecomputations: 100
17
- },
18
- hotPaths: {
19
- enabled: true,
20
- threshold: 10
21
- },
22
- reporting: {
23
- console: true
24
- }
25
- };
26
- }
27
- function createFeatureTree(signalTree, initial, options) {
28
- const env = options.env ?? process?.env?.['NODE_ENV'] ?? 'production';
29
- const isDev = env === 'development';
30
- const isTest = env === 'test';
31
- const enhancers = [];
32
- if (isDev || isTest) {
33
- const guardrailsConfig = resolveGuardrailsConfig(options.guardrails);
34
- if (guardrailsConfig) {
35
- enhancers.push(guardrails(guardrailsConfig));
36
- }
37
- }
38
- if (options.enhancers?.length) {
39
- enhancers.push(...options.enhancers);
40
- }
41
- const tree = signalTree(initial);
42
- let enhanced = tree;
43
- for (const enhancer of enhancers) {
44
- enhanced = enhanced.with(enhancer);
45
- }
46
- return enhanced;
47
- }
48
- function createAngularFeatureTree(signalTree, initial, options) {
49
- const isDev = Boolean(ngDevMode);
50
- return createFeatureTree(signalTree, initial, {
51
- ...options,
52
- env: isDev ? 'development' : 'production'
53
- });
54
- }
55
- function createAppShellTree(signalTree, initial) {
56
- return createFeatureTree(signalTree, initial, {
57
- guardrails: {
58
- budgets: {
59
- maxUpdateTime: 4,
60
- maxMemory: 20
61
- },
62
- hotPaths: {
63
- threshold: 5
64
- },
65
- customRules: [rules.noDeepNesting(3)]
66
- }
67
- });
68
- }
69
- function createPerformanceTree(signalTree, initial, name) {
70
- return createFeatureTree(signalTree, initial, {
71
- guardrails: {
72
- budgets: {
73
- maxUpdateTime: 8,
74
- maxRecomputations: 200
75
- },
76
- hotPaths: {
77
- threshold: 50
78
- },
79
- memoryLeaks: {
80
- enabled: false
81
- },
82
- reporting: {
83
- interval: 10000
84
- }
85
- }
86
- });
87
- }
88
- function createFormTree(signalTree, initial, formName) {
89
- return createFeatureTree(signalTree, initial, {
90
- guardrails: {
91
- customRules: [rules.noDeepNesting(4), rules.maxPayloadSize(50), rules.noSensitiveData()]
92
- }
93
- });
94
- }
95
- function createCacheTree(signalTree, initial) {
96
- return createFeatureTree(signalTree, initial, {
97
- guardrails: {
98
- mode: 'silent',
99
- memoryLeaks: {
100
- enabled: false
101
- }
102
- }});
103
- }
104
- function createTestTree(signalTree, initial, overrides) {
105
- return createFeatureTree(signalTree, initial, {
106
- env: 'test',
107
- guardrails: {
108
- mode: 'throw',
109
- budgets: {
110
- maxUpdateTime: 5,
111
- maxRecomputations: 50
112
- },
113
- customRules: [rules.noFunctionsInState(), rules.noDeepNesting(4)],
114
- ...overrides
115
- }
116
- });
117
- }
118
-
119
- export { createAngularFeatureTree, createAppShellTree, createCacheTree, createFeatureTree, createFormTree, createPerformanceTree, createTestTree };
package/dist/index.js DELETED
@@ -1,2 +0,0 @@
1
- export * from './lib/guardrails.js';
2
- export * from './lib/rules.js';
@@ -1,625 +0,0 @@
1
- import { getPathNotifier } from '@signaltree/core';
2
- import { deepEqual } from '@signaltree/shared';
3
-
4
- function isFunction(value) {
5
- return typeof value === 'function';
6
- }
7
- function isObjectLike(value) {
8
- return typeof value === 'object' && value !== null;
9
- }
10
- function resolveEnabledFlag(option) {
11
- if (option === undefined) {
12
- return true;
13
- }
14
- if (isFunction(option)) {
15
- try {
16
- return option();
17
- } catch {
18
- return true;
19
- }
20
- }
21
- return option;
22
- }
23
- function tryStructuredClone(value) {
24
- const cloneFn = globalThis.structuredClone;
25
- if (isFunction(cloneFn)) {
26
- try {
27
- return cloneFn(value);
28
- } catch {}
29
- }
30
- try {
31
- return JSON.parse(JSON.stringify(value));
32
- } catch {
33
- return value;
34
- }
35
- }
36
- function isDevEnvironment() {
37
- if (__DEV__ !== undefined) return __DEV__;
38
- if (process?.env?.['NODE_ENV'] === 'production') return false;
39
- if (ngDevMode != null) return Boolean(ngDevMode);
40
- return true;
41
- }
42
- const MAX_TIMING_SAMPLES = 1000;
43
- const RECOMPUTATION_WINDOW_MS = 1000;
44
- const POLLING_INTERVAL_MS = 50;
45
- function guardrails(config = {}) {
46
- return function (tree) {
47
- const enabled = resolveEnabledFlag(config.enabled);
48
- if (!isDevEnvironment() || !enabled) {
49
- return tree;
50
- }
51
- const stats = createRuntimeStats();
52
- const context = {
53
- tree: tree,
54
- config: config,
55
- stats,
56
- issues: [],
57
- hotPaths: [],
58
- currentUpdate: null,
59
- suppressed: false,
60
- timings: [],
61
- hotPathData: new Map(),
62
- issueMap: new Map(),
63
- signalUsage: new Map(),
64
- pathRecomputations: new Map(),
65
- memoryHistory: [],
66
- recomputationLog: [],
67
- previousState: tryStructuredClone(tree()),
68
- disposed: false
69
- };
70
- tree['__devHooks'] = {
71
- onRecompute: (path, count) => {
72
- if (!context.disposed && !context.suppressed) {
73
- recordRecomputations(path, context, count, Date.now());
74
- const maxRecomputations = config.budgets?.maxRecomputations;
75
- if (maxRecomputations && context.stats.recomputationCount > maxRecomputations) {
76
- addIssue(context, {
77
- type: 'budget',
78
- severity: 'error',
79
- message: `Recomputation budget exceeded: ${context.stats.recomputationCount} > ${maxRecomputations}`,
80
- path,
81
- count: 1
82
- });
83
- }
84
- }
85
- }
86
- };
87
- const stopChangeDetection = startChangeDetection(context);
88
- const stopMonitoring = startMonitoring(context);
89
- const teardown = () => {
90
- if (context.disposed) return;
91
- context.disposed = true;
92
- stopChangeDetection();
93
- stopMonitoring();
94
- };
95
- const originalDestroy = tree.destroy?.bind(tree);
96
- tree.destroy = () => {
97
- teardown();
98
- if (originalDestroy) {
99
- originalDestroy();
100
- }
101
- };
102
- tree['__guardrails'] = createAPI(context, teardown);
103
- return tree;
104
- };
105
- }
106
- const withGuardrails = Object.assign(guardrails, {});
107
- function startChangeDetection(context) {
108
- if (!context.config.changeDetection?.disablePathNotifier) {
109
- try {
110
- const pathNotifier = getPathNotifier();
111
- if (pathNotifier) {
112
- const unsubscribe = pathNotifier.subscribe('**', (value, prev, path) => {
113
- handlePathNotifierChange(context, path, value, prev);
114
- });
115
- return unsubscribe;
116
- }
117
- } catch {}
118
- }
119
- try {
120
- const maybeSubscribe = context.tree.subscribe;
121
- if (typeof maybeSubscribe === 'function') {
122
- const unsubscribe = maybeSubscribe.call(context.tree, () => {
123
- handleStateChange(context);
124
- });
125
- return unsubscribe;
126
- }
127
- } catch {}
128
- return startPollingChangeDetection(context);
129
- }
130
- function handlePathNotifierChange(context, path, newValue, oldValue) {
131
- if (context.disposed || context.suppressed) return;
132
- const startTime = performance.now();
133
- const timestamp = Date.now();
134
- const detail = {
135
- path,
136
- segments: path.split('.'),
137
- oldValue,
138
- newValue
139
- };
140
- analyzePreUpdate(context, detail, {});
141
- const duration = performance.now() - startTime;
142
- const diffRatio = calculateDiffRatio(oldValue, newValue);
143
- analyzePostUpdate(context, detail, duration, diffRatio);
144
- trackHotPath(context, path, duration);
145
- trackSignalUsage(context, path, timestamp);
146
- updateTimingStats(context, duration);
147
- updateSignalStats(context, timestamp);
148
- context.previousState = tryStructuredClone(context.tree());
149
- }
150
- function handleStateChange(context) {
151
- if (context.disposed || context.suppressed) return;
152
- const currentState = context.tree();
153
- const previousState = context.previousState;
154
- if (!previousState) {
155
- context.previousState = tryStructuredClone(currentState);
156
- return;
157
- }
158
- const equal = deepEqual(currentState, previousState);
159
- if (!equal) {
160
- const startTime = performance.now();
161
- const timestamp = Date.now();
162
- const changedPaths = detectChangedPaths(previousState, currentState);
163
- for (const path of changedPaths) {
164
- const detail = {
165
- path,
166
- segments: path.split('.'),
167
- oldValue: getValueAtPath(previousState, path.split('.')),
168
- newValue: getValueAtPath(currentState, path.split('.'))
169
- };
170
- analyzePreUpdate(context, detail, {});
171
- const duration = performance.now() - startTime;
172
- const diffRatio = calculateDiffRatio(detail.oldValue, detail.newValue);
173
- analyzePostUpdate(context, detail, duration, diffRatio);
174
- trackHotPath(context, path, duration);
175
- trackSignalUsage(context, path, timestamp);
176
- }
177
- const totalDuration = performance.now() - startTime;
178
- updateTimingStats(context, totalDuration);
179
- updateSignalStats(context, timestamp);
180
- context.previousState = tryStructuredClone(currentState);
181
- }
182
- }
183
- function startPollingChangeDetection(context) {
184
- const pollForChanges = () => {
185
- handleStateChange(context);
186
- };
187
- context.pollingIntervalId = setInterval(pollForChanges, POLLING_INTERVAL_MS);
188
- return () => {
189
- if (context.pollingIntervalId) {
190
- clearInterval(context.pollingIntervalId);
191
- context.pollingIntervalId = undefined;
192
- }
193
- };
194
- }
195
- function detectChangedPaths(oldState, newState, prefix = '') {
196
- const changes = [];
197
- const allKeys = new Set([...Object.keys(oldState || {}), ...Object.keys(newState || {})]);
198
- for (const key of allKeys) {
199
- const path = prefix ? `${prefix}.${key}` : key;
200
- const oldVal = oldState?.[key];
201
- const newVal = newState?.[key];
202
- if (oldVal === newVal) continue;
203
- if (isObjectLike(oldVal) && isObjectLike(newVal) && !Array.isArray(oldVal) && !Array.isArray(newVal)) {
204
- changes.push(...detectChangedPaths(oldVal, newVal, path));
205
- } else {
206
- changes.push(path);
207
- }
208
- }
209
- return changes;
210
- }
211
- function createRuntimeStats() {
212
- return {
213
- updateCount: 0,
214
- totalUpdateTime: 0,
215
- avgUpdateTime: 0,
216
- p50UpdateTime: 0,
217
- p95UpdateTime: 0,
218
- p99UpdateTime: 0,
219
- maxUpdateTime: 0,
220
- recomputationCount: 0,
221
- recomputationsPerSecond: 0,
222
- signalCount: 0,
223
- signalRetention: 0,
224
- unreadSignalCount: 0,
225
- memoryGrowthRate: 0,
226
- hotPathCount: 0,
227
- violationCount: 0
228
- };
229
- }
230
- function updatePercentiles(context) {
231
- if (context.timings.length === 0) return;
232
- const sorted = [...context.timings].sort((a, b) => a - b);
233
- context.stats.p50UpdateTime = sorted[Math.floor(sorted.length * 0.5)] || 0;
234
- context.stats.p95UpdateTime = sorted[Math.floor(sorted.length * 0.95)] || 0;
235
- context.stats.p99UpdateTime = sorted[Math.floor(sorted.length * 0.99)] || 0;
236
- }
237
- function calculateDiffRatio(oldValue, newValue) {
238
- if (!isComparableRecord(oldValue) || !isComparableRecord(newValue)) {
239
- return Object.is(oldValue, newValue) ? 0 : 1;
240
- }
241
- if (oldValue === newValue) return 0;
242
- const oldKeys = new Set(Object.keys(oldValue));
243
- const newKeys = new Set(Object.keys(newValue));
244
- const allKeys = new Set([...oldKeys, ...newKeys]);
245
- let changed = 0;
246
- for (const key of allKeys) {
247
- if (!oldKeys.has(key) || !newKeys.has(key) || oldValue[key] !== newValue[key]) {
248
- changed++;
249
- }
250
- }
251
- return allKeys.size === 0 ? 0 : changed / allKeys.size;
252
- }
253
- function analyzePreUpdate(context, detail, metadata) {
254
- if (!context.config.customRules) return;
255
- for (const rule of context.config.customRules) {
256
- evaluateRule(context, rule, {
257
- path: detail.segments,
258
- value: detail.newValue,
259
- oldValue: detail.oldValue,
260
- metadata,
261
- tree: context.tree,
262
- stats: context.stats
263
- });
264
- }
265
- }
266
- function analyzePostUpdate(context, detail, duration, diffRatio, isPrimary) {
267
- if (context.config.budgets?.maxUpdateTime && duration > context.config.budgets.maxUpdateTime) {
268
- addIssue(context, {
269
- type: 'budget',
270
- severity: 'error',
271
- message: `Update took ${duration.toFixed(2)}ms (budget: ${context.config.budgets.maxUpdateTime}ms)`,
272
- path: detail.path,
273
- count: 1
274
- });
275
- }
276
- const minDiff = context.config.analysis?.minDiffForParentReplace ?? 0.8;
277
- if (context.config.analysis?.warnParentReplace && diffRatio > minDiff) {
278
- addIssue(context, {
279
- type: 'analysis',
280
- severity: 'warning',
281
- message: `High diff ratio (${(diffRatio * 100).toFixed(0)}%) - consider scoped updates`,
282
- path: detail.path,
283
- count: 1,
284
- diffRatio
285
- });
286
- }
287
- }
288
- function trackHotPath(context, path, duration) {
289
- if (!context.config.hotPaths?.enabled) return;
290
- const pathKey = Array.isArray(path) ? path.join('.') : path;
291
- const now = Date.now();
292
- const windowMs = context.config.hotPaths.windowMs || 1000;
293
- let data = context.hotPathData.get(pathKey);
294
- if (!data) {
295
- data = {
296
- count: 0,
297
- lastUpdate: now,
298
- durations: []
299
- };
300
- context.hotPathData.set(pathKey, data);
301
- }
302
- if (now - data.lastUpdate > windowMs) {
303
- data.count = 0;
304
- data.durations = [];
305
- }
306
- data.count++;
307
- data.durations.push(duration);
308
- data.lastUpdate = now;
309
- const threshold = context.config.hotPaths.threshold || 10;
310
- const updatesPerSecond = data.count / windowMs * 1000;
311
- if (updatesPerSecond > threshold) {
312
- const sorted = [...data.durations].sort((a, b) => a - b);
313
- const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
314
- const avg = data.durations.reduce((sum, d) => sum + d, 0) / data.durations.length;
315
- updateHotPath(context, {
316
- path: pathKey,
317
- updatesPerSecond,
318
- heatScore: Math.min(100, updatesPerSecond / threshold * 50),
319
- downstreamEffects: 0,
320
- avgDuration: avg,
321
- p95Duration: p95
322
- });
323
- }
324
- }
325
- function trackSignalUsage(context, path, timestamp) {
326
- const key = Array.isArray(path) ? path.join('.') : path;
327
- const entry = context.signalUsage.get(key) ?? {
328
- updates: 0,
329
- lastSeen: timestamp
330
- };
331
- entry.updates += 1;
332
- entry.lastSeen = timestamp;
333
- context.signalUsage.set(key, entry);
334
- }
335
- function updateSignalStats(context, timestamp) {
336
- const retentionWindow = context.config.memoryLeaks?.checkInterval ?? 5000;
337
- const historyWindow = Math.max(retentionWindow, 1000);
338
- const signalCount = context.signalUsage.size;
339
- context.stats.signalCount = signalCount;
340
- const staleCount = [...context.signalUsage.values()].filter(entry => timestamp - entry.lastSeen > retentionWindow).length;
341
- context.stats.signalRetention = staleCount;
342
- context.stats.unreadSignalCount = 0;
343
- context.memoryHistory.push({
344
- timestamp,
345
- count: signalCount
346
- });
347
- context.memoryHistory = context.memoryHistory.filter(entry => timestamp - entry.timestamp <= historyWindow);
348
- const baseline = context.memoryHistory[0]?.count ?? signalCount;
349
- const growth = baseline === 0 ? 0 : (signalCount - baseline) / Math.max(1, baseline);
350
- context.stats.memoryGrowthRate = growth;
351
- }
352
- function recordRecomputations(path, context, count, timestamp) {
353
- const currentPathCount = context.pathRecomputations.get(path) ?? 0;
354
- context.pathRecomputations.set(path, currentPathCount + count);
355
- if (count > 0) {
356
- context.stats.recomputationCount += count;
357
- for (let i = 0; i < count; i++) {
358
- context.recomputationLog.push(timestamp);
359
- }
360
- }
361
- if (context.recomputationLog.length) {
362
- context.recomputationLog = context.recomputationLog.filter(value => timestamp - value <= RECOMPUTATION_WINDOW_MS);
363
- }
364
- context.stats.recomputationsPerSecond = context.recomputationLog.length;
365
- }
366
- function updateHotPath(context, hotPath) {
367
- const existing = context.hotPaths.find(h => h.path === hotPath.path);
368
- if (existing) {
369
- Object.assign(existing, hotPath);
370
- } else {
371
- context.hotPaths.push(hotPath);
372
- const topN = context.config.hotPaths?.topN || 5;
373
- if (context.hotPaths.length > topN) {
374
- context.hotPaths.sort((a, b) => b.heatScore - a.heatScore);
375
- context.hotPaths.length = topN;
376
- }
377
- }
378
- context.stats.hotPathCount = context.hotPaths.length;
379
- }
380
- function evaluateRule(context, rule, ruleContext) {
381
- const handleFailure = () => {
382
- const message = typeof rule.message === 'function' ? rule.message(ruleContext) : rule.message;
383
- addIssue(context, {
384
- type: 'rule',
385
- severity: rule.severity || 'warning',
386
- message,
387
- path: ruleContext.path.join('.'),
388
- count: 1,
389
- metadata: {
390
- rule: rule.name
391
- }
392
- });
393
- };
394
- try {
395
- const result = rule.test(ruleContext);
396
- if (result instanceof Promise) {
397
- result.then(outcome => {
398
- if (!outcome) {
399
- handleFailure();
400
- }
401
- }).catch(error => {
402
- console.warn(`[Guardrails] Rule ${rule.name} rejected:`, error);
403
- });
404
- return;
405
- }
406
- if (!result) {
407
- handleFailure();
408
- }
409
- } catch (error) {
410
- console.warn(`[Guardrails] Rule ${rule.name} threw error:`, error);
411
- }
412
- }
413
- function addIssue(context, issue) {
414
- if (context.suppressed) return;
415
- if (context.config.reporting?.aggregateWarnings !== false) {
416
- const key = `${issue.type}:${issue.path}:${issue.message}`;
417
- const existing = context.issueMap.get(key);
418
- if (existing) {
419
- existing.count++;
420
- return;
421
- }
422
- context.issueMap.set(key, issue);
423
- } else {
424
- const key = `${issue.type}:${issue.path}:${issue.message}:${Date.now()}:${Math.random()}`;
425
- context.issueMap.set(key, issue);
426
- }
427
- if (context.config.mode === 'throw') {
428
- throw new Error(`[Guardrails] ${issue.message}`);
429
- }
430
- }
431
- function startMonitoring(context) {
432
- const interval = setInterval(() => {
433
- if (context.disposed) {
434
- clearInterval(interval);
435
- return;
436
- }
437
- checkMemory(context);
438
- maybeReport(context);
439
- }, context.config.reporting?.interval || 5000);
440
- return () => clearInterval(interval);
441
- }
442
- function checkMemory(context) {
443
- if (!context.config.memoryLeaks?.enabled) return;
444
- const now = Date.now();
445
- const retentionWindow = context.config.memoryLeaks?.checkInterval ?? 5000;
446
- const retentionThreshold = context.config.memoryLeaks?.retentionThreshold ?? 100;
447
- const growthThreshold = context.config.memoryLeaks?.growthRate ?? 0.2;
448
- const staleCount = [...context.signalUsage.values()].filter(entry => now - entry.lastSeen > retentionWindow).length;
449
- context.stats.signalRetention = staleCount;
450
- const exceedsRetention = context.stats.signalRetention > retentionThreshold;
451
- const exceedsGrowth = context.stats.memoryGrowthRate > growthThreshold;
452
- if (exceedsRetention || exceedsGrowth) {
453
- addIssue(context, {
454
- type: 'memory',
455
- severity: 'warning',
456
- message: `Potential memory leak detected (signals: ${context.stats.signalCount}, growth ${(context.stats.memoryGrowthRate * 100).toFixed(1)}%)`,
457
- path: 'root',
458
- count: 1,
459
- metadata: {
460
- signalCount: context.stats.signalCount,
461
- growth: context.stats.memoryGrowthRate
462
- }
463
- });
464
- }
465
- }
466
- function maybeReport(context) {
467
- if (context.config.reporting?.console === false) return;
468
- const report = generateReport(context);
469
- if (context.config.reporting?.customReporter) {
470
- context.config.reporting.customReporter(report);
471
- }
472
- if (context.config.reporting?.console && context.issues.length > 0) {
473
- reportToConsole(report, context.config.reporting.console === 'verbose');
474
- }
475
- context.issues = [];
476
- context.issueMap.clear();
477
- }
478
- function reportToConsole(report, verbose) {
479
- console.group('[Guardrails] Performance Report');
480
- logIssues(report.issues);
481
- logHotPaths(report.hotPaths);
482
- if (verbose) {
483
- logVerboseStats(report);
484
- }
485
- console.groupEnd();
486
- }
487
- function logIssues(issues) {
488
- if (issues.length === 0) return;
489
- console.warn(`${issues.length} issues detected:`);
490
- for (const issue of issues) {
491
- const prefix = getSeverityPrefix(issue.severity);
492
- const countSuffix = issue.count > 1 ? ` (x${issue.count})` : '';
493
- const message = `${prefix} [${issue.type}] ${issue.message}${countSuffix}`;
494
- console.log(` ${message}`);
495
- }
496
- }
497
- function logHotPaths(hotPaths) {
498
- if (hotPaths.length === 0) return;
499
- console.log(`\nHot Paths (${hotPaths.length}):`);
500
- for (const hp of hotPaths) {
501
- const entry = ` 🔥 ${hp.path}: ${hp.updatesPerSecond.toFixed(1)}/s (heat: ${hp.heatScore.toFixed(0)})`;
502
- console.log(entry);
503
- }
504
- }
505
- function logVerboseStats(report) {
506
- console.log('\nStats:', report.stats);
507
- console.log('Budgets:', report.budgets);
508
- }
509
- function getSeverityPrefix(severity) {
510
- if (severity === 'error') return '❌';
511
- if (severity === 'warning') return '⚠️';
512
- return 'ℹ️';
513
- }
514
- function generateReport(context) {
515
- const memoryCurrent = context.stats.signalCount;
516
- const memoryLimit = context.config.budgets?.maxMemory ?? 50;
517
- const recomputationCurrent = context.stats.recomputationsPerSecond;
518
- const recomputationLimit = context.config.budgets?.maxRecomputations ?? 100;
519
- const budgets = {
520
- updateTime: createBudgetItem(context.stats.avgUpdateTime, context.config.budgets?.maxUpdateTime || 16),
521
- memory: createBudgetItem(memoryCurrent, memoryLimit),
522
- recomputations: createBudgetItem(recomputationCurrent, recomputationLimit)
523
- };
524
- return {
525
- timestamp: Date.now(),
526
- treeId: context.config.treeId,
527
- issues: Array.from(context.issueMap.values()),
528
- hotPaths: context.hotPaths,
529
- budgets,
530
- stats: context.stats,
531
- recommendations: generateRecommendations(context)
532
- };
533
- }
534
- function createBudgetItem(current, limit) {
535
- if (limit <= 0) {
536
- return {
537
- current,
538
- limit,
539
- usage: 0,
540
- status: 'ok'
541
- };
542
- }
543
- const usage = current / limit * 100;
544
- const threshold = 80;
545
- let status;
546
- if (usage > 100) {
547
- status = 'exceeded';
548
- } else if (usage > threshold) {
549
- status = 'warning';
550
- } else {
551
- status = 'ok';
552
- }
553
- return {
554
- current,
555
- limit,
556
- usage,
557
- status
558
- };
559
- }
560
- function generateRecommendations(context) {
561
- const recommendations = [];
562
- if (context.hotPaths.length > 0) {
563
- recommendations.push('Consider batching or debouncing updates to hot paths');
564
- }
565
- if (context.stats.avgUpdateTime > 10) {
566
- recommendations.push('Average update time is high - review update logic');
567
- }
568
- return recommendations;
569
- }
570
- function createAPI(context, teardown) {
571
- return {
572
- getReport: () => generateReport(context),
573
- getStats: () => context.stats,
574
- suppress: fn => {
575
- const was = context.suppressed;
576
- context.suppressed = true;
577
- try {
578
- fn();
579
- } finally {
580
- context.suppressed = was;
581
- }
582
- },
583
- dispose: () => {
584
- teardown();
585
- const finalReport = generateReport(context);
586
- if (context.config.reporting?.console !== false) {
587
- console.log('[Guardrails] Final report on disposal:', finalReport);
588
- }
589
- if (context.config.reporting?.customReporter) {
590
- context.config.reporting.customReporter(finalReport);
591
- }
592
- }
593
- };
594
- }
595
- function getValueAtPath(source, segments) {
596
- let current = source;
597
- for (const segment of segments) {
598
- if (!isObjectLike(current)) {
599
- return undefined;
600
- }
601
- current = current[segment];
602
- }
603
- return current;
604
- }
605
- function isPlainObject(value) {
606
- if (!value || typeof value !== 'object') return false;
607
- const proto = Object.getPrototypeOf(value);
608
- return proto === Object.prototype || proto === null;
609
- }
610
- function updateTimingStats(context, duration) {
611
- context.timings.push(duration);
612
- if (context.timings.length > MAX_TIMING_SAMPLES) {
613
- context.timings.shift();
614
- }
615
- context.stats.updateCount++;
616
- context.stats.totalUpdateTime += duration;
617
- context.stats.avgUpdateTime = context.stats.totalUpdateTime / context.stats.updateCount;
618
- context.stats.maxUpdateTime = Math.max(context.stats.maxUpdateTime, duration);
619
- updatePercentiles(context);
620
- }
621
- function isComparableRecord(value) {
622
- return isPlainObject(value);
623
- }
624
-
625
- export { guardrails, withGuardrails };
package/dist/noop.js DELETED
@@ -1,21 +0,0 @@
1
- const noopRule = name => ({
2
- name,
3
- description: 'No-op guardrail',
4
- test: () => true,
5
- message: '',
6
- severity: 'info'
7
- });
8
- function guardrails(config = {}) {
9
- return tree => {
10
- return tree;
11
- };
12
- }
13
- const rules = {
14
- noDeepNesting: (_maxDepth = 5) => noopRule('noop'),
15
- noFunctionsInState: () => noopRule('noop'),
16
- noCacheInPersistence: () => noopRule('noop'),
17
- maxPayloadSize: (_maxKB = 100) => noopRule('noop'),
18
- noSensitiveData: (_sensitiveKeys = ['password', 'token', 'secret', 'apiKey']) => noopRule('noop')
19
- };
20
-
21
- export { guardrails, rules };
@@ -1,19 +0,0 @@
1
- import type { ISignalTree, TreeConfig, Enhancer } from '@signaltree/core';
2
- import type { GuardrailsConfig } from '../lib/types';
3
- type SignalTreeFactory<T extends Record<string, unknown>> = (initial: T, config?: TreeConfig) => ISignalTree<T>;
4
- interface FeatureTreeOptions<T extends Record<string, unknown>> {
5
- name: string;
6
- env?: 'development' | 'test' | 'staging' | 'production';
7
- persistence?: boolean | Record<string, unknown>;
8
- guardrails?: boolean | GuardrailsConfig<T>;
9
- devtools?: boolean;
10
- enhancers?: Enhancer<unknown>[];
11
- }
12
- export declare function createFeatureTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T, options: FeatureTreeOptions<T>): ISignalTree<T>;
13
- export declare function createAngularFeatureTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T, options: Omit<FeatureTreeOptions<T>, 'env'>): ISignalTree<T>;
14
- export declare function createAppShellTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T): ISignalTree<T>;
15
- export declare function createPerformanceTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T, name: string): ISignalTree<T>;
16
- export declare function createFormTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T, formName: string): ISignalTree<T>;
17
- export declare function createCacheTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T): ISignalTree<T>;
18
- export declare function createTestTree<T extends Record<string, unknown>>(signalTree: SignalTreeFactory<T>, initial: T, overrides?: Partial<GuardrailsConfig<T>>): ISignalTree<T>;
19
- export {};
package/src/index.d.ts DELETED
@@ -1,4 +0,0 @@
1
- import { guardrails } from './lib/guardrails';
2
- import { rules } from './lib/rules';
3
- export { guardrails, rules };
4
- export type * from './lib/types';
@@ -1,6 +0,0 @@
1
- import type { ISignalTree } from '@signaltree/core';
2
- import type { GuardrailsConfig, GuardrailsAPI } from './types';
3
- export declare function guardrails(config?: GuardrailsConfig<any>): <Tree extends ISignalTree<any>>(tree: Tree) => Tree & {
4
- __guardrails?: GuardrailsAPI;
5
- };
6
- export declare const withGuardrails: typeof guardrails;
@@ -1,8 +0,0 @@
1
- import type { GuardrailRule } from './types';
2
- export declare const rules: {
3
- noDeepNesting: (maxDepth?: number) => GuardrailRule;
4
- noFunctionsInState: () => GuardrailRule;
5
- noCacheInPersistence: () => GuardrailRule;
6
- maxPayloadSize: (maxKB?: number) => GuardrailRule;
7
- noSensitiveData: (sensitiveKeys?: string[]) => GuardrailRule;
8
- };
@@ -1,141 +0,0 @@
1
- import type { ISignalTree } from '@signaltree/core';
2
- export interface GuardrailsConfig<T = Record<string, unknown>> {
3
- mode?: 'warn' | 'throw' | 'silent';
4
- enabled?: boolean | (() => boolean);
5
- changeDetection?: {
6
- disablePathNotifier?: boolean;
7
- };
8
- budgets?: {
9
- maxUpdateTime?: number;
10
- maxMemory?: number;
11
- maxRecomputations?: number;
12
- maxTreeDepth?: number;
13
- alertThreshold?: number;
14
- };
15
- hotPaths?: {
16
- enabled?: boolean;
17
- threshold?: number;
18
- topN?: number;
19
- trackDownstream?: boolean;
20
- windowMs?: number;
21
- };
22
- memoryLeaks?: {
23
- enabled?: boolean;
24
- checkInterval?: number;
25
- retentionThreshold?: number;
26
- growthRate?: number;
27
- trackUnread?: boolean;
28
- };
29
- customRules?: GuardrailRule<T>[];
30
- suppression?: {
31
- autoSuppress?: Array<'hydrate' | 'reset' | 'bulk' | 'migration' | 'time-travel' | 'serialization'>;
32
- respectMetadata?: boolean;
33
- };
34
- analysis?: {
35
- forbidRootRead?: boolean;
36
- forbidSliceRootRead?: boolean | string[];
37
- maxDepsPerComputed?: number;
38
- warnParentReplace?: boolean;
39
- minDiffForParentReplace?: number;
40
- detectThrashing?: boolean;
41
- maxRerunsPerSecond?: number;
42
- };
43
- reporting?: {
44
- interval?: number;
45
- console?: boolean | 'verbose';
46
- customReporter?: (report: GuardrailsReport) => void;
47
- aggregateWarnings?: boolean;
48
- maxIssuesPerReport?: number;
49
- };
50
- treeId?: string;
51
- }
52
- export interface UpdateMetadata {
53
- intent?: 'hydrate' | 'reset' | 'bulk' | 'migration' | 'user' | 'system';
54
- source?: 'serialization' | 'time-travel' | 'devtools' | 'user' | 'system';
55
- suppressGuardrails?: boolean;
56
- timestamp?: number;
57
- correlationId?: string;
58
- [key: string]: unknown;
59
- }
60
- export interface GuardrailRule<T = Record<string, unknown>> {
61
- name: string;
62
- description?: string;
63
- test: (context: RuleContext<T>) => boolean | Promise<boolean>;
64
- message: string | ((context: RuleContext<T>) => string);
65
- severity?: 'error' | 'warning' | 'info';
66
- fix?: (context: RuleContext<T>) => void;
67
- tags?: string[];
68
- }
69
- export interface RuleContext<T = Record<string, unknown>> {
70
- path: string[];
71
- value: unknown;
72
- oldValue?: unknown;
73
- metadata?: UpdateMetadata;
74
- tree: ISignalTree<T>;
75
- duration?: number;
76
- diffRatio?: number;
77
- recomputeCount?: number;
78
- downstreamEffects?: number;
79
- isUnread?: boolean;
80
- stats: RuntimeStats;
81
- }
82
- export interface RuntimeStats {
83
- updateCount: number;
84
- totalUpdateTime: number;
85
- avgUpdateTime: number;
86
- p50UpdateTime: number;
87
- p95UpdateTime: number;
88
- p99UpdateTime: number;
89
- maxUpdateTime: number;
90
- recomputationCount: number;
91
- recomputationsPerSecond: number;
92
- signalCount: number;
93
- signalRetention: number;
94
- unreadSignalCount: number;
95
- memoryGrowthRate: number;
96
- hotPathCount: number;
97
- violationCount: number;
98
- }
99
- export interface GuardrailIssue {
100
- type: 'budget' | 'hot-path' | 'memory' | 'rule' | 'analysis';
101
- severity: 'error' | 'warning' | 'info';
102
- message: string;
103
- path?: string;
104
- count: number;
105
- diffRatio?: number;
106
- metadata?: Record<string, unknown>;
107
- }
108
- export interface HotPath {
109
- path: string;
110
- updatesPerSecond: number;
111
- heatScore: number;
112
- downstreamEffects: number;
113
- avgDuration: number;
114
- p95Duration: number;
115
- }
116
- export interface BudgetStatus {
117
- updateTime: BudgetItem;
118
- memory: BudgetItem;
119
- recomputations: BudgetItem;
120
- }
121
- export interface BudgetItem {
122
- current: number;
123
- limit: number;
124
- usage: number;
125
- status: 'ok' | 'warning' | 'exceeded';
126
- }
127
- export interface GuardrailsReport {
128
- timestamp: number;
129
- treeId?: string;
130
- issues: GuardrailIssue[];
131
- hotPaths: HotPath[];
132
- budgets: BudgetStatus;
133
- stats: RuntimeStats;
134
- recommendations: string[];
135
- }
136
- export interface GuardrailsAPI {
137
- getReport(): GuardrailsReport;
138
- getStats(): RuntimeStats;
139
- suppress(fn: () => void): void;
140
- dispose(): void;
141
- }
package/src/noop.d.ts DELETED
@@ -1,10 +0,0 @@
1
- import type { GuardrailsConfig, GuardrailRule } from './lib/types';
2
- export declare function guardrails(config?: GuardrailsConfig<any>): <S>(tree: import("@signaltree/core").SignalTree<S>) => import("@signaltree/core").SignalTree<S>;
3
- export declare const rules: {
4
- noDeepNesting: (_maxDepth?: number) => GuardrailRule<Record<string, unknown>>;
5
- noFunctionsInState: () => GuardrailRule<Record<string, unknown>>;
6
- noCacheInPersistence: () => GuardrailRule<Record<string, unknown>>;
7
- maxPayloadSize: (_maxKB?: number) => GuardrailRule<Record<string, unknown>>;
8
- noSensitiveData: (_sensitiveKeys?: string[]) => GuardrailRule<Record<string, unknown>>;
9
- };
10
- export * from './lib/types';