@signaltree/guardrails 4.0.15 → 4.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # @signaltree/guardrails Changelog
2
+
3
+ ## 1.1.0 (2025-11-12)
4
+
5
+ - Added recomputation tracking, percentile reporting, and diff ratio analysis
6
+ - Implemented memory-leak heuristics, hot-path sampling, and guardrails disposal API
7
+ - Introduced factory helpers (`createFeatureTree`, `createPerformanceTree`, etc.)
8
+ - Integrated guardrails into release automation, bundle analysis, and coverage scripts
9
+ - Published comprehensive documentation set (migration guide, troubleshooting, limitations)
10
+
11
+ ## 1.0.0 (2025-10-??)
12
+
13
+ - Initial release with core budgets, hot-path detection, and custom rule engine
14
+ - Dev-only exports to guarantee zero production overhead
15
+
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @signaltree/guardrails
2
+
3
+ > Development-only performance monitoring and anti-pattern detection for SignalTree
4
+
5
+ ## Features
6
+
7
+ - ✅ **Zero production cost** - Dev-only via conditional exports
8
+ - ✅ **Performance budgets** - Update time, memory, recomputations
9
+ - ✅ **Hot path analysis** - Automatic detection with heat scores
10
+ - ✅ **Memory leak detection** - Retention and growth tracking
11
+ - ✅ **Custom rules engine** - Team-specific policies
12
+ - ✅ **Intent-aware suppression** - Smart noise reduction
13
+ - ✅ **Percentile reporting** - P50/P95/P99 metrics
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install --save-dev @signaltree/guardrails
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ ```typescript
24
+ import { signalTree } from '@signaltree/core';
25
+ import { withGuardrails } from '@signaltree/guardrails';
26
+
27
+ const tree = signalTree({ count: 0 }).with(withGuardrails({
28
+ budgets: { maxUpdateTime: 16 },
29
+ hotPaths: { threshold: 10 },
30
+ }));
31
+ ```
32
+
33
+ ## Using Factories
34
+
35
+ ```typescript
36
+ import { signalTree } from '@signaltree/core';
37
+ import { createFeatureTree } from '@signaltree/guardrails/factories';
38
+
39
+ const tree = createFeatureTree(signalTree, { data: [] }, {
40
+ name: 'dashboard',
41
+ guardrails: true,
42
+ });
43
+ ```
44
+
45
+ ## Configuration
46
+
47
+ See [docs/guardrails](../../docs/guardrails) for complete documentation.
48
+
49
+ ## License
50
+
51
+ MIT
@@ -0,0 +1 @@
1
+ export * from "./src/factories/index";
@@ -0,0 +1,119 @@
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 };
@@ -0,0 +1,612 @@
1
+ function isFunction(value) {
2
+ return typeof value === 'function';
3
+ }
4
+ function isString(value) {
5
+ return typeof value === 'string';
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 supportsMiddleware(tree) {
24
+ const candidate = tree;
25
+ return isFunction(candidate.addTap) && isFunction(candidate.removeTap);
26
+ }
27
+ function tryStructuredClone(value) {
28
+ const cloneFn = globalThis.structuredClone;
29
+ if (isFunction(cloneFn)) {
30
+ try {
31
+ return cloneFn(value);
32
+ } catch {}
33
+ }
34
+ return value;
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
+ function withGuardrails(config = {}) {
45
+ return tree => {
46
+ const enabled = resolveEnabledFlag(config.enabled);
47
+ if (!isDevEnvironment() || !enabled) {
48
+ return tree;
49
+ }
50
+ if (!supportsMiddleware(tree)) {
51
+ console.warn('[Guardrails] Tree does not expose middleware hooks; guardrails disabled.');
52
+ return tree;
53
+ }
54
+ const stats = createRuntimeStats();
55
+ const context = {
56
+ tree,
57
+ config,
58
+ stats,
59
+ issues: [],
60
+ hotPaths: [],
61
+ currentUpdate: null,
62
+ suppressed: false,
63
+ timings: [],
64
+ hotPathData: new Map(),
65
+ issueMap: new Map(),
66
+ signalUsage: new Map(),
67
+ memoryHistory: [],
68
+ recomputationLog: [],
69
+ disposed: false
70
+ };
71
+ const middlewareId = `guardrails:${config.treeId ?? 'tree'}:${Math.random().toString(36).slice(2)}`;
72
+ context.middlewareId = middlewareId;
73
+ const middleware = createGuardrailsMiddleware(context);
74
+ tree.addTap(middleware);
75
+ const stopMonitoring = startMonitoring(context);
76
+ const teardown = () => {
77
+ if (context.disposed) return;
78
+ context.disposed = true;
79
+ stopMonitoring();
80
+ try {
81
+ tree.removeTap(middlewareId);
82
+ } catch {}
83
+ };
84
+ const originalDestroy = tree.destroy?.bind(tree);
85
+ tree.destroy = () => {
86
+ teardown();
87
+ if (originalDestroy) {
88
+ originalDestroy();
89
+ }
90
+ };
91
+ tree['__guardrails'] = createAPI(context, teardown);
92
+ return tree;
93
+ };
94
+ }
95
+ function createRuntimeStats() {
96
+ return {
97
+ updateCount: 0,
98
+ totalUpdateTime: 0,
99
+ avgUpdateTime: 0,
100
+ p50UpdateTime: 0,
101
+ p95UpdateTime: 0,
102
+ p99UpdateTime: 0,
103
+ maxUpdateTime: 0,
104
+ recomputationCount: 0,
105
+ recomputationsPerSecond: 0,
106
+ signalCount: 0,
107
+ signalRetention: 0,
108
+ unreadSignalCount: 0,
109
+ memoryGrowthRate: 0,
110
+ hotPathCount: 0,
111
+ violationCount: 0
112
+ };
113
+ }
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
+ function updatePercentiles(context) {
160
+ if (context.timings.length === 0) return;
161
+ const sorted = [...context.timings].sort((a, b) => a - b);
162
+ context.stats.p50UpdateTime = sorted[Math.floor(sorted.length * 0.5)] || 0;
163
+ context.stats.p95UpdateTime = sorted[Math.floor(sorted.length * 0.95)] || 0;
164
+ context.stats.p99UpdateTime = sorted[Math.floor(sorted.length * 0.99)] || 0;
165
+ }
166
+ function calculateDiffRatio(oldValue, newValue) {
167
+ if (!isComparableRecord(oldValue) || !isComparableRecord(newValue)) {
168
+ return Object.is(oldValue, newValue) ? 0 : 1;
169
+ }
170
+ if (oldValue === newValue) return 0;
171
+ const oldKeys = new Set(Object.keys(oldValue));
172
+ const newKeys = new Set(Object.keys(newValue));
173
+ const allKeys = new Set([...oldKeys, ...newKeys]);
174
+ let changed = 0;
175
+ for (const key of allKeys) {
176
+ if (!oldKeys.has(key) || !newKeys.has(key) || oldValue[key] !== newValue[key]) {
177
+ changed++;
178
+ }
179
+ }
180
+ return allKeys.size === 0 ? 0 : changed / allKeys.size;
181
+ }
182
+ function analyzePreUpdate(context, detail, metadata) {
183
+ if (!context.config.customRules) return;
184
+ for (const rule of context.config.customRules) {
185
+ evaluateRule(context, rule, {
186
+ path: detail.segments,
187
+ value: detail.newValue,
188
+ oldValue: detail.oldValue,
189
+ metadata,
190
+ tree: context.tree,
191
+ stats: context.stats
192
+ });
193
+ }
194
+ }
195
+ function analyzePostUpdate(context, detail, duration, diffRatio, isPrimary) {
196
+ if (isPrimary && context.config.budgets?.maxUpdateTime && duration > context.config.budgets.maxUpdateTime) {
197
+ addIssue(context, {
198
+ type: 'budget',
199
+ severity: 'error',
200
+ message: `Update took ${duration.toFixed(2)}ms (budget: ${context.config.budgets.maxUpdateTime}ms)`,
201
+ path: detail.path,
202
+ count: 1
203
+ });
204
+ }
205
+ const minDiff = context.config.analysis?.minDiffForParentReplace ?? 0.8;
206
+ if (context.config.analysis?.warnParentReplace && diffRatio > minDiff) {
207
+ addIssue(context, {
208
+ type: 'analysis',
209
+ severity: 'warning',
210
+ message: `High diff ratio (${(diffRatio * 100).toFixed(0)}%) - consider scoped updates`,
211
+ path: detail.path,
212
+ count: 1,
213
+ diffRatio
214
+ });
215
+ }
216
+ }
217
+ function trackHotPath(context, path, duration) {
218
+ if (!context.config.hotPaths?.enabled) return;
219
+ const pathKey = Array.isArray(path) ? path.join('.') : path;
220
+ const now = Date.now();
221
+ const windowMs = context.config.hotPaths.windowMs || 1000;
222
+ let data = context.hotPathData.get(pathKey);
223
+ if (!data) {
224
+ data = {
225
+ count: 0,
226
+ lastUpdate: now,
227
+ durations: []
228
+ };
229
+ context.hotPathData.set(pathKey, data);
230
+ }
231
+ if (now - data.lastUpdate > windowMs) {
232
+ data.count = 0;
233
+ data.durations = [];
234
+ }
235
+ data.count++;
236
+ data.durations.push(duration);
237
+ data.lastUpdate = now;
238
+ const threshold = context.config.hotPaths.threshold || 10;
239
+ const updatesPerSecond = data.count / windowMs * 1000;
240
+ if (updatesPerSecond > threshold) {
241
+ const sorted = [...data.durations].sort((a, b) => a - b);
242
+ const p95 = sorted[Math.floor(sorted.length * 0.95)] || 0;
243
+ const avg = data.durations.reduce((sum, d) => sum + d, 0) / data.durations.length;
244
+ updateHotPath(context, {
245
+ path: pathKey,
246
+ updatesPerSecond,
247
+ heatScore: Math.min(100, updatesPerSecond / threshold * 50),
248
+ downstreamEffects: 0,
249
+ avgDuration: avg,
250
+ p95Duration: p95
251
+ });
252
+ }
253
+ }
254
+ function trackSignalUsage(context, path, timestamp) {
255
+ const key = Array.isArray(path) ? path.join('.') : path;
256
+ const entry = context.signalUsage.get(key) ?? {
257
+ updates: 0,
258
+ lastSeen: timestamp
259
+ };
260
+ entry.updates += 1;
261
+ entry.lastSeen = timestamp;
262
+ context.signalUsage.set(key, entry);
263
+ }
264
+ function updateSignalStats(context, timestamp) {
265
+ const retentionWindow = context.config.memoryLeaks?.checkInterval ?? 5000;
266
+ const historyWindow = Math.max(retentionWindow, 1000);
267
+ const signalCount = context.signalUsage.size;
268
+ context.stats.signalCount = signalCount;
269
+ const staleCount = [...context.signalUsage.values()].filter(entry => timestamp - entry.lastSeen > retentionWindow).length;
270
+ context.stats.signalRetention = staleCount;
271
+ context.stats.unreadSignalCount = 0;
272
+ context.memoryHistory.push({
273
+ timestamp,
274
+ count: signalCount
275
+ });
276
+ context.memoryHistory = context.memoryHistory.filter(entry => timestamp - entry.timestamp <= historyWindow);
277
+ const baseline = context.memoryHistory[0]?.count ?? signalCount;
278
+ const growth = baseline === 0 ? 0 : (signalCount - baseline) / Math.max(1, baseline);
279
+ context.stats.memoryGrowthRate = growth;
280
+ }
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
+ function updateHotPath(context, hotPath) {
294
+ const existing = context.hotPaths.find(h => h.path === hotPath.path);
295
+ if (existing) {
296
+ Object.assign(existing, hotPath);
297
+ } else {
298
+ context.hotPaths.push(hotPath);
299
+ const topN = context.config.hotPaths?.topN || 5;
300
+ if (context.hotPaths.length > topN) {
301
+ context.hotPaths.sort((a, b) => b.heatScore - a.heatScore);
302
+ context.hotPaths.length = topN;
303
+ }
304
+ }
305
+ context.stats.hotPathCount = context.hotPaths.length;
306
+ }
307
+ function evaluateRule(context, rule, ruleContext) {
308
+ const handleFailure = () => {
309
+ const message = typeof rule.message === 'function' ? rule.message(ruleContext) : rule.message;
310
+ addIssue(context, {
311
+ type: 'rule',
312
+ severity: rule.severity || 'warning',
313
+ message,
314
+ path: ruleContext.path.join('.'),
315
+ count: 1,
316
+ metadata: {
317
+ rule: rule.name
318
+ }
319
+ });
320
+ };
321
+ try {
322
+ const result = rule.test(ruleContext);
323
+ if (result instanceof Promise) {
324
+ result.then(outcome => {
325
+ if (!outcome) {
326
+ handleFailure();
327
+ }
328
+ }).catch(error => {
329
+ console.warn(`[Guardrails] Rule ${rule.name} rejected:`, error);
330
+ });
331
+ return;
332
+ }
333
+ if (!result) {
334
+ handleFailure();
335
+ }
336
+ } catch (error) {
337
+ console.warn(`[Guardrails] Rule ${rule.name} threw error:`, error);
338
+ }
339
+ }
340
+ function addIssue(context, issue) {
341
+ if (context.suppressed) return;
342
+ if (context.config.reporting?.aggregateWarnings !== false) {
343
+ const key = `${issue.type}:${issue.path}:${issue.message}`;
344
+ const existing = context.issueMap.get(key);
345
+ if (existing) {
346
+ existing.count++;
347
+ return;
348
+ }
349
+ context.issueMap.set(key, issue);
350
+ }
351
+ context.issues.push(issue);
352
+ context.stats.violationCount++;
353
+ if (context.config.mode === 'throw') {
354
+ throw new Error(`[Guardrails] ${issue.message}`);
355
+ }
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
+ function startMonitoring(context) {
367
+ const interval = setInterval(() => {
368
+ if (context.disposed) {
369
+ clearInterval(interval);
370
+ return;
371
+ }
372
+ checkMemory(context);
373
+ maybeReport(context);
374
+ }, context.config.reporting?.interval || 5000);
375
+ return () => clearInterval(interval);
376
+ }
377
+ function checkMemory(context) {
378
+ if (!context.config.memoryLeaks?.enabled) return;
379
+ const now = Date.now();
380
+ const retentionWindow = context.config.memoryLeaks?.checkInterval ?? 5000;
381
+ const retentionThreshold = context.config.memoryLeaks?.retentionThreshold ?? 100;
382
+ const growthThreshold = context.config.memoryLeaks?.growthRate ?? 0.2;
383
+ const staleCount = [...context.signalUsage.values()].filter(entry => now - entry.lastSeen > retentionWindow).length;
384
+ context.stats.signalRetention = staleCount;
385
+ const exceedsRetention = context.stats.signalRetention > retentionThreshold;
386
+ const exceedsGrowth = context.stats.memoryGrowthRate > growthThreshold;
387
+ if (exceedsRetention || exceedsGrowth) {
388
+ addIssue(context, {
389
+ type: 'memory',
390
+ severity: 'warning',
391
+ message: `Potential memory leak detected (signals: ${context.stats.signalCount}, growth ${(context.stats.memoryGrowthRate * 100).toFixed(1)}%)`,
392
+ path: 'root',
393
+ count: 1,
394
+ metadata: {
395
+ signalCount: context.stats.signalCount,
396
+ growth: context.stats.memoryGrowthRate
397
+ }
398
+ });
399
+ }
400
+ }
401
+ function maybeReport(context) {
402
+ if (context.config.reporting?.console === false) return;
403
+ const report = generateReport(context);
404
+ if (context.config.reporting?.customReporter) {
405
+ context.config.reporting.customReporter(report);
406
+ }
407
+ if (context.config.reporting?.console && context.issues.length > 0) {
408
+ reportToConsole(report, context.config.reporting.console === 'verbose');
409
+ }
410
+ context.issues = [];
411
+ context.issueMap.clear();
412
+ }
413
+ function reportToConsole(report, verbose) {
414
+ console.group('[Guardrails] Performance Report');
415
+ logIssues(report.issues);
416
+ logHotPaths(report.hotPaths);
417
+ if (verbose) {
418
+ logVerboseStats(report);
419
+ }
420
+ console.groupEnd();
421
+ }
422
+ function logIssues(issues) {
423
+ if (issues.length === 0) return;
424
+ console.warn(`${issues.length} issues detected:`);
425
+ for (const issue of issues) {
426
+ const prefix = getSeverityPrefix(issue.severity);
427
+ const countSuffix = issue.count > 1 ? ` (x${issue.count})` : '';
428
+ const message = `${prefix} [${issue.type}] ${issue.message}${countSuffix}`;
429
+ console.log(` ${message}`);
430
+ }
431
+ }
432
+ function logHotPaths(hotPaths) {
433
+ if (hotPaths.length === 0) return;
434
+ console.log(`\nHot Paths (${hotPaths.length}):`);
435
+ for (const hp of hotPaths) {
436
+ const entry = ` 🔥 ${hp.path}: ${hp.updatesPerSecond.toFixed(1)}/s (heat: ${hp.heatScore.toFixed(0)})`;
437
+ console.log(entry);
438
+ }
439
+ }
440
+ function logVerboseStats(report) {
441
+ console.log('\nStats:', report.stats);
442
+ console.log('Budgets:', report.budgets);
443
+ }
444
+ function getSeverityPrefix(severity) {
445
+ if (severity === 'error') return '❌';
446
+ if (severity === 'warning') return '⚠️';
447
+ return 'ℹ️';
448
+ }
449
+ function generateReport(context) {
450
+ const memoryCurrent = context.stats.signalCount;
451
+ const memoryLimit = context.config.budgets?.maxMemory ?? 50;
452
+ const recomputationCurrent = context.stats.recomputationsPerSecond;
453
+ const recomputationLimit = context.config.budgets?.maxRecomputations ?? 100;
454
+ const budgets = {
455
+ updateTime: createBudgetItem(context.stats.avgUpdateTime, context.config.budgets?.maxUpdateTime || 16),
456
+ memory: createBudgetItem(memoryCurrent, memoryLimit),
457
+ recomputations: createBudgetItem(recomputationCurrent, recomputationLimit)
458
+ };
459
+ return {
460
+ timestamp: Date.now(),
461
+ treeId: context.config.treeId,
462
+ issues: Array.from(context.issueMap.values()),
463
+ hotPaths: context.hotPaths,
464
+ budgets,
465
+ stats: context.stats,
466
+ recommendations: generateRecommendations(context)
467
+ };
468
+ }
469
+ function createBudgetItem(current, limit) {
470
+ if (limit <= 0) {
471
+ return {
472
+ current,
473
+ limit,
474
+ usage: 0,
475
+ status: 'ok'
476
+ };
477
+ }
478
+ const usage = current / limit * 100;
479
+ const threshold = 80;
480
+ let status;
481
+ if (usage > 100) {
482
+ status = 'exceeded';
483
+ } else if (usage > threshold) {
484
+ status = 'warning';
485
+ } else {
486
+ status = 'ok';
487
+ }
488
+ return {
489
+ current,
490
+ limit,
491
+ usage,
492
+ status
493
+ };
494
+ }
495
+ function generateRecommendations(context) {
496
+ const recommendations = [];
497
+ if (context.hotPaths.length > 0) {
498
+ recommendations.push('Consider batching or debouncing updates to hot paths');
499
+ }
500
+ if (context.stats.avgUpdateTime > 10) {
501
+ recommendations.push('Average update time is high - review update logic');
502
+ }
503
+ return recommendations;
504
+ }
505
+ function createAPI(context, teardown) {
506
+ return {
507
+ getReport: () => generateReport(context),
508
+ getStats: () => context.stats,
509
+ suppress: fn => {
510
+ const was = context.suppressed;
511
+ context.suppressed = true;
512
+ try {
513
+ fn();
514
+ } finally {
515
+ context.suppressed = was;
516
+ }
517
+ },
518
+ dispose: () => {
519
+ teardown();
520
+ const finalReport = generateReport(context);
521
+ if (context.config.reporting?.console !== false) {
522
+ console.log('[Guardrails] Final report on disposal:', finalReport);
523
+ }
524
+ if (context.config.reporting?.customReporter) {
525
+ context.config.reporting.customReporter(finalReport);
526
+ }
527
+ }
528
+ };
529
+ }
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
+ function getValueAtPath(source, segments) {
580
+ let current = source;
581
+ for (const segment of segments) {
582
+ if (!isObjectLike(current)) {
583
+ return undefined;
584
+ }
585
+ current = current[segment];
586
+ }
587
+ return current;
588
+ }
589
+ function captureValue(value) {
590
+ return tryStructuredClone(value);
591
+ }
592
+ function isPlainObject(value) {
593
+ if (!value || typeof value !== 'object') return false;
594
+ const proto = Object.getPrototypeOf(value);
595
+ return proto === Object.prototype || proto === null;
596
+ }
597
+ function updateTimingStats(context, duration) {
598
+ context.timings.push(duration);
599
+ if (context.timings.length > MAX_TIMING_SAMPLES) {
600
+ context.timings.shift();
601
+ }
602
+ context.stats.updateCount++;
603
+ context.stats.totalUpdateTime += duration;
604
+ context.stats.avgUpdateTime = context.stats.totalUpdateTime / context.stats.updateCount;
605
+ context.stats.maxUpdateTime = Math.max(context.stats.maxUpdateTime, duration);
606
+ updatePercentiles(context);
607
+ }
608
+ function isComparableRecord(value) {
609
+ return isPlainObject(value);
610
+ }
611
+
612
+ export { withGuardrails };
@@ -0,0 +1,62 @@
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.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/noop";
package/dist/noop.js ADDED
@@ -0,0 +1,21 @@
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 };
package/package.json CHANGED
@@ -1,60 +1,69 @@
1
1
  {
2
2
  "name": "@signaltree/guardrails",
3
- "version": "4.0.15",
3
+ "version": "4.1.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/index.cjs",
8
- "module": "./dist/index.js",
9
- "types": "./dist/index.d.ts",
7
+ "main": "./dist/lib/guardrails.js",
8
+ "module": "./dist/lib/guardrails.js",
9
+ "types": "./src/index.d.ts",
10
10
  "exports": {
11
11
  ".": {
12
- "types": "./dist/index.d.ts",
12
+ "types": "./src/index.d.ts",
13
13
  "development": {
14
- "import": "./dist/index.js",
15
- "require": "./dist/index.cjs"
14
+ "import": "./dist/lib/guardrails.js",
15
+ "default": "./dist/lib/guardrails.js"
16
16
  },
17
17
  "production": {
18
18
  "import": "./dist/noop.js",
19
- "require": "./dist/noop.cjs"
19
+ "default": "./dist/noop.js"
20
20
  },
21
21
  "default": "./dist/noop.js"
22
22
  },
23
23
  "./factories": {
24
- "types": "./dist/factories/index.d.ts",
24
+ "types": "./src/factories/index.d.ts",
25
25
  "development": {
26
26
  "import": "./dist/factories/index.js",
27
- "require": "./dist/factories/index.cjs"
27
+ "default": "./dist/factories/index.js"
28
28
  },
29
29
  "production": {
30
30
  "import": "./dist/noop.js",
31
- "require": "./dist/noop.cjs"
31
+ "default": "./dist/noop.js"
32
32
  },
33
33
  "default": "./dist/noop.js"
34
34
  },
35
+ "./noop": {
36
+ "types": "./src/noop.d.ts",
37
+ "import": "./dist/noop.js",
38
+ "default": "./dist/noop.js"
39
+ },
35
40
  "./package.json": "./package.json"
36
41
  },
37
42
  "files": [
38
43
  "dist",
44
+ "src",
39
45
  "README.md",
40
46
  "CHANGELOG.md"
41
47
  ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
42
51
  "scripts": {
43
- "build": "tsup",
44
- "test": "jest",
45
- "test:watch": "jest --watch",
46
- "test:coverage": "jest --coverage",
47
- "lint": "eslint src --ext .ts",
48
- "type-check": "tsc --noEmit"
52
+ "build": "nx build guardrails",
53
+ "test": "nx test guardrails",
54
+ "lint": "nx lint guardrails",
55
+ "test:watch": "nx test guardrails --watch",
56
+ "test:coverage": "nx test guardrails --coverage",
57
+ "type-check": "tsc --project tsconfig.lib.json --noEmit"
49
58
  },
50
59
  "peerDependencies": {
51
- "@signaltree/core": "^4.0.0"
60
+ "@signaltree/core": "^4.1.0",
61
+ "tslib": "^2.0.0"
52
62
  },
53
63
  "devDependencies": {
54
64
  "@signaltree/core": "workspace:*",
55
65
  "@signaltree/shared": "workspace:*",
56
- "@signaltree/types": "workspace:*",
57
- "tsup": "^8.0.0"
66
+ "@signaltree/types": "workspace:*"
58
67
  },
59
68
  "keywords": [
60
69
  "signaltree",
@@ -0,0 +1,20 @@
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 ADDED
@@ -0,0 +1,4 @@
1
+ import { withGuardrails } from './lib/guardrails';
2
+ import { rules } from './lib/rules';
3
+ export { withGuardrails, rules };
4
+ export type * from './lib/types';
@@ -0,0 +1,3 @@
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>;
@@ -0,0 +1,8 @@
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
+ };
@@ -0,0 +1,138 @@
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
+ budgets?: {
6
+ maxUpdateTime?: number;
7
+ maxMemory?: number;
8
+ maxRecomputations?: number;
9
+ maxTreeDepth?: number;
10
+ alertThreshold?: number;
11
+ };
12
+ hotPaths?: {
13
+ enabled?: boolean;
14
+ threshold?: number;
15
+ topN?: number;
16
+ trackDownstream?: boolean;
17
+ windowMs?: number;
18
+ };
19
+ memoryLeaks?: {
20
+ enabled?: boolean;
21
+ checkInterval?: number;
22
+ retentionThreshold?: number;
23
+ growthRate?: number;
24
+ trackUnread?: boolean;
25
+ };
26
+ customRules?: GuardrailRule<T>[];
27
+ suppression?: {
28
+ autoSuppress?: Array<'hydrate' | 'reset' | 'bulk' | 'migration' | 'time-travel' | 'serialization'>;
29
+ respectMetadata?: boolean;
30
+ };
31
+ analysis?: {
32
+ forbidRootRead?: boolean;
33
+ forbidSliceRootRead?: boolean | string[];
34
+ maxDepsPerComputed?: number;
35
+ warnParentReplace?: boolean;
36
+ minDiffForParentReplace?: number;
37
+ detectThrashing?: boolean;
38
+ maxRerunsPerSecond?: number;
39
+ };
40
+ reporting?: {
41
+ interval?: number;
42
+ console?: boolean | 'verbose';
43
+ customReporter?: (report: GuardrailsReport) => void;
44
+ aggregateWarnings?: boolean;
45
+ maxIssuesPerReport?: number;
46
+ };
47
+ treeId?: string;
48
+ }
49
+ export interface UpdateMetadata {
50
+ intent?: 'hydrate' | 'reset' | 'bulk' | 'migration' | 'user' | 'system';
51
+ source?: 'serialization' | 'time-travel' | 'devtools' | 'user' | 'system';
52
+ suppressGuardrails?: boolean;
53
+ timestamp?: number;
54
+ correlationId?: string;
55
+ [key: string]: unknown;
56
+ }
57
+ export interface GuardrailRule<T extends Record<string, unknown> = Record<string, unknown>> {
58
+ name: string;
59
+ description?: string;
60
+ test: (context: RuleContext<T>) => boolean | Promise<boolean>;
61
+ message: string | ((context: RuleContext<T>) => string);
62
+ severity?: 'error' | 'warning' | 'info';
63
+ fix?: (context: RuleContext<T>) => void;
64
+ tags?: string[];
65
+ }
66
+ export interface RuleContext<T extends Record<string, unknown> = Record<string, unknown>> {
67
+ path: string[];
68
+ value: unknown;
69
+ oldValue?: unknown;
70
+ metadata?: UpdateMetadata;
71
+ tree: SignalTree<T>;
72
+ duration?: number;
73
+ diffRatio?: number;
74
+ recomputeCount?: number;
75
+ downstreamEffects?: number;
76
+ isUnread?: boolean;
77
+ stats: RuntimeStats;
78
+ }
79
+ export interface RuntimeStats {
80
+ updateCount: number;
81
+ totalUpdateTime: number;
82
+ avgUpdateTime: number;
83
+ p50UpdateTime: number;
84
+ p95UpdateTime: number;
85
+ p99UpdateTime: number;
86
+ maxUpdateTime: number;
87
+ recomputationCount: number;
88
+ recomputationsPerSecond: number;
89
+ signalCount: number;
90
+ signalRetention: number;
91
+ unreadSignalCount: number;
92
+ memoryGrowthRate: number;
93
+ hotPathCount: number;
94
+ violationCount: number;
95
+ }
96
+ export interface GuardrailIssue {
97
+ type: 'budget' | 'hot-path' | 'memory' | 'rule' | 'analysis';
98
+ severity: 'error' | 'warning' | 'info';
99
+ message: string;
100
+ path?: string;
101
+ count: number;
102
+ diffRatio?: number;
103
+ metadata?: Record<string, unknown>;
104
+ }
105
+ export interface HotPath {
106
+ path: string;
107
+ updatesPerSecond: number;
108
+ heatScore: number;
109
+ downstreamEffects: number;
110
+ avgDuration: number;
111
+ p95Duration: number;
112
+ }
113
+ export interface BudgetStatus {
114
+ updateTime: BudgetItem;
115
+ memory: BudgetItem;
116
+ recomputations: BudgetItem;
117
+ }
118
+ export interface BudgetItem {
119
+ current: number;
120
+ limit: number;
121
+ usage: number;
122
+ status: 'ok' | 'warning' | 'exceeded';
123
+ }
124
+ export interface GuardrailsReport {
125
+ timestamp: number;
126
+ treeId?: string;
127
+ issues: GuardrailIssue[];
128
+ hotPaths: HotPath[];
129
+ budgets: BudgetStatus;
130
+ stats: RuntimeStats;
131
+ recommendations: string[];
132
+ }
133
+ export interface GuardrailsAPI {
134
+ getReport(): GuardrailsReport;
135
+ getStats(): RuntimeStats;
136
+ suppress(fn: () => void): void;
137
+ dispose(): void;
138
+ }
package/src/noop.d.ts ADDED
@@ -0,0 +1,11 @@
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';