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