margin-ts 0.6.0 → 0.6.1

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.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Express middleware adapter for margin.
3
+ *
4
+ * Tracks per-route health: latency, error rate, request rate.
5
+ * Exposes /margin/health endpoint with typed classifications.
6
+ *
7
+ * Usage:
8
+ * import express from 'express';
9
+ * import { marginMiddleware, marginHealthRoute } from 'margin-ts/adapters/express';
10
+ *
11
+ * const app = express();
12
+ * app.use(marginMiddleware());
13
+ * app.get('/margin/health', marginHealthRoute());
14
+ *
15
+ * Zero dependencies beyond margin-ts core (express types are optional).
16
+ */
17
+ import { Thresholds } from '../index.js';
18
+ interface RouteMetrics {
19
+ route: string;
20
+ totalRequests: number;
21
+ totalErrors: number;
22
+ total4xx: number;
23
+ latencies: number[];
24
+ timestamps: Date[];
25
+ maxWindow: number;
26
+ }
27
+ export interface EndpointThresholds {
28
+ p50Latency: Thresholds;
29
+ p99Latency: Thresholds;
30
+ errorRate: Thresholds;
31
+ requestRate: Thresholds;
32
+ }
33
+ export declare const DEFAULT_THRESHOLDS: EndpointThresholds;
34
+ export interface MarginMiddlewareOptions {
35
+ /** Track per-route or aggregate only (default: true) */
36
+ perRoute?: boolean;
37
+ /** Max latency samples to keep per route (default: 200) */
38
+ window?: number;
39
+ /** Custom thresholds */
40
+ thresholds?: EndpointThresholds;
41
+ /** Routes to ignore (e.g. ['/margin/health', '/favicon.ico']) */
42
+ ignore?: string[];
43
+ /** Normalize route paths: replace numeric segments with :id (default: true) */
44
+ normalizePaths?: boolean;
45
+ }
46
+ /** The shared state object — accessible for custom endpoints */
47
+ export interface MarginState {
48
+ routes: Map<string, RouteMetrics>;
49
+ aggregate: RouteMetrics;
50
+ options: Required<MarginMiddlewareOptions>;
51
+ }
52
+ /**
53
+ * Express middleware that tracks request latency and error rates.
54
+ *
55
+ * Attach to your app before routes:
56
+ * app.use(marginMiddleware());
57
+ */
58
+ export declare function marginMiddleware(options?: MarginMiddlewareOptions): any;
59
+ export interface HealthResponse {
60
+ status: string;
61
+ expression: string;
62
+ aggregate: Record<string, unknown>;
63
+ routes?: Record<string, Record<string, unknown>>;
64
+ drift?: Record<string, unknown>;
65
+ }
66
+ /**
67
+ * Express route handler for /margin/health.
68
+ *
69
+ * Returns typed health classification for all tracked routes.
70
+ *
71
+ * app.get('/margin/health', marginHealthRoute());
72
+ *
73
+ * Or pass the middleware directly:
74
+ * const mw = marginMiddleware();
75
+ * app.use(mw);
76
+ * app.get('/margin/health', marginHealthRoute(mw));
77
+ */
78
+ export declare function marginHealthRoute(middleware?: any): any;
79
+ export {};
80
+ //# sourceMappingURL=express.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.d.ts","sourceRoot":"","sources":["../../src/adapters/express.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAGL,UAAU,EAcX,MAAM,aAAa,CAAC;AAMrB,UAAU,YAAY;IACpB,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,UAAU,EAAE,IAAI,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;CACnB;AA8BD,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,UAAU,CAAC;IACvB,UAAU,EAAE,UAAU,CAAC;IACvB,SAAS,EAAE,UAAU,CAAC;IACtB,WAAW,EAAE,UAAU,CAAC;CACzB;AAED,eAAO,MAAM,kBAAkB,EAAE,kBAKhC,CAAC;AAmFF,MAAM,WAAW,uBAAuB;IACtC,wDAAwD;IACxD,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2DAA2D;IAC3D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,UAAU,CAAC,EAAE,kBAAkB,CAAC;IAChC,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,+EAA+E;IAC/E,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,gEAAgE;AAChE,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAClC,SAAS,EAAE,YAAY,CAAC;IACxB,OAAO,EAAE,QAAQ,CAAC,uBAAuB,CAAC,CAAC;CAC5C;AAMD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,GAAG,CAoD3E;AAMD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACjD,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,CAAC,EAAE,GAAG,GAAG,GAAG,CA4DvD"}
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+ /**
3
+ * Express middleware adapter for margin.
4
+ *
5
+ * Tracks per-route health: latency, error rate, request rate.
6
+ * Exposes /margin/health endpoint with typed classifications.
7
+ *
8
+ * Usage:
9
+ * import express from 'express';
10
+ * import { marginMiddleware, marginHealthRoute } from 'margin-ts/adapters/express';
11
+ *
12
+ * const app = express();
13
+ * app.use(marginMiddleware());
14
+ * app.get('/margin/health', marginHealthRoute());
15
+ *
16
+ * Zero dependencies beyond margin-ts core (express types are optional).
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.DEFAULT_THRESHOLDS = void 0;
20
+ exports.marginMiddleware = marginMiddleware;
21
+ exports.marginHealthRoute = marginHealthRoute;
22
+ const index_js_1 = require("../index.js");
23
+ function createRouteMetrics(route, maxWindow = 200) {
24
+ return {
25
+ route,
26
+ totalRequests: 0,
27
+ totalErrors: 0,
28
+ total4xx: 0,
29
+ latencies: [],
30
+ timestamps: [],
31
+ maxWindow,
32
+ };
33
+ }
34
+ function recordRequest(rm, latencyMs, statusCode) {
35
+ rm.totalRequests++;
36
+ if (statusCode >= 500)
37
+ rm.totalErrors++;
38
+ if (statusCode >= 400 && statusCode < 500)
39
+ rm.total4xx++;
40
+ rm.latencies.push(latencyMs);
41
+ rm.timestamps.push(new Date());
42
+ if (rm.latencies.length > rm.maxWindow) {
43
+ rm.latencies.shift();
44
+ rm.timestamps.shift();
45
+ }
46
+ }
47
+ exports.DEFAULT_THRESHOLDS = {
48
+ p50Latency: (0, index_js_1.createThresholds)(100, 500, false), // ms, lower is better
49
+ p99Latency: (0, index_js_1.createThresholds)(500, 2000, false), // ms, lower is better
50
+ errorRate: (0, index_js_1.createThresholds)(0.01, 0.10, false), // ratio, lower is better
51
+ requestRate: (0, index_js_1.createThresholds)(1.0, 0.1, true), // req/s, higher is better
52
+ };
53
+ // -----------------------------------------------------------------------
54
+ // Metrics → Observations
55
+ // -----------------------------------------------------------------------
56
+ function percentile(sorted, p) {
57
+ if (sorted.length === 0)
58
+ return 0;
59
+ const idx = Math.ceil(sorted.length * p) - 1;
60
+ return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
61
+ }
62
+ function classifyRoute(rm, thresholds, now) {
63
+ if (rm.latencies.length < 3)
64
+ return [];
65
+ const sorted = [...rm.latencies].sort((a, b) => a - b);
66
+ const p50 = percentile(sorted, 0.5);
67
+ const p99 = percentile(sorted, 0.99);
68
+ const errorRate = rm.totalRequests > 0 ? rm.totalErrors / rm.totalRequests : 0;
69
+ // Request rate: requests in window / window duration
70
+ let requestRate = 0;
71
+ if (rm.timestamps.length >= 2) {
72
+ const windowMs = rm.timestamps[rm.timestamps.length - 1].getTime() - rm.timestamps[0].getTime();
73
+ if (windowMs > 0)
74
+ requestRate = (rm.timestamps.length / windowMs) * 1000;
75
+ }
76
+ const prefix = rm.route === '*' ? '' : `${rm.route}:`;
77
+ const observations = [
78
+ {
79
+ name: `${prefix}p50_latency`,
80
+ health: (0, index_js_1.classify)(p50, index_js_1.Confidence.HIGH, thresholds.p50Latency),
81
+ value: p50,
82
+ baseline: thresholds.p50Latency.intact,
83
+ confidence: index_js_1.Confidence.HIGH,
84
+ higherIsBetter: false,
85
+ provenance: [],
86
+ measuredAt: now,
87
+ },
88
+ {
89
+ name: `${prefix}p99_latency`,
90
+ health: (0, index_js_1.classify)(p99, index_js_1.Confidence.HIGH, thresholds.p99Latency),
91
+ value: p99,
92
+ baseline: thresholds.p99Latency.intact,
93
+ confidence: index_js_1.Confidence.HIGH,
94
+ higherIsBetter: false,
95
+ provenance: [],
96
+ measuredAt: now,
97
+ },
98
+ {
99
+ name: `${prefix}error_rate`,
100
+ health: (0, index_js_1.classify)(errorRate, index_js_1.Confidence.HIGH, thresholds.errorRate),
101
+ value: errorRate,
102
+ baseline: 0.001,
103
+ confidence: index_js_1.Confidence.HIGH,
104
+ higherIsBetter: false,
105
+ provenance: [],
106
+ measuredAt: now,
107
+ },
108
+ {
109
+ name: `${prefix}request_rate`,
110
+ health: (0, index_js_1.classify)(requestRate, index_js_1.Confidence.MODERATE, thresholds.requestRate),
111
+ value: requestRate,
112
+ baseline: 10.0,
113
+ confidence: index_js_1.Confidence.MODERATE,
114
+ higherIsBetter: true,
115
+ provenance: [],
116
+ measuredAt: now,
117
+ },
118
+ ];
119
+ return observations;
120
+ }
121
+ function normalizePath(path) {
122
+ return path.replace(/\/\d+/g, '/:id').replace(/\/[0-9a-f]{24}/g, '/:id');
123
+ }
124
+ /**
125
+ * Express middleware that tracks request latency and error rates.
126
+ *
127
+ * Attach to your app before routes:
128
+ * app.use(marginMiddleware());
129
+ */
130
+ function marginMiddleware(options = {}) {
131
+ const opts = {
132
+ perRoute: options.perRoute ?? true,
133
+ window: options.window ?? 200,
134
+ thresholds: options.thresholds ?? exports.DEFAULT_THRESHOLDS,
135
+ ignore: options.ignore ?? ['/margin/health', '/favicon.ico'],
136
+ normalizePaths: options.normalizePaths ?? true,
137
+ };
138
+ const state = {
139
+ routes: new Map(),
140
+ aggregate: createRouteMetrics('*', opts.window),
141
+ options: opts,
142
+ };
143
+ const middleware = (req, res, next) => {
144
+ const path = req.path || req.url || '/';
145
+ if (opts.ignore.includes(path)) {
146
+ next();
147
+ return;
148
+ }
149
+ const start = Date.now();
150
+ // Hook into response finish
151
+ const onFinish = () => {
152
+ res.removeListener('finish', onFinish);
153
+ const latency = Date.now() - start;
154
+ const status = res.statusCode || 200;
155
+ // Aggregate
156
+ recordRequest(state.aggregate, latency, status);
157
+ // Per-route
158
+ if (opts.perRoute) {
159
+ const route = opts.normalizePaths ? normalizePath(path) : path;
160
+ if (!state.routes.has(route)) {
161
+ state.routes.set(route, createRouteMetrics(route, opts.window));
162
+ }
163
+ recordRequest(state.routes.get(route), latency, status);
164
+ }
165
+ };
166
+ res.on('finish', onFinish);
167
+ next();
168
+ };
169
+ // Attach state to middleware for access by health route
170
+ middleware.__marginState = state;
171
+ return middleware;
172
+ }
173
+ /**
174
+ * Express route handler for /margin/health.
175
+ *
176
+ * Returns typed health classification for all tracked routes.
177
+ *
178
+ * app.get('/margin/health', marginHealthRoute());
179
+ *
180
+ * Or pass the middleware directly:
181
+ * const mw = marginMiddleware();
182
+ * app.use(mw);
183
+ * app.get('/margin/health', marginHealthRoute(mw));
184
+ */
185
+ function marginHealthRoute(middleware) {
186
+ return (req, res) => {
187
+ // Find state from middleware
188
+ let state;
189
+ if (middleware && middleware.__marginState) {
190
+ state = middleware.__marginState;
191
+ }
192
+ else if (req.app) {
193
+ // Walk through app middleware stack to find ours
194
+ const stack = req.app._router?.stack || [];
195
+ for (const layer of stack) {
196
+ if (layer.handle?.__marginState) {
197
+ state = layer.handle.__marginState;
198
+ break;
199
+ }
200
+ }
201
+ }
202
+ if (!state) {
203
+ res.status(500).json({ error: 'margin middleware not found' });
204
+ return;
205
+ }
206
+ const now = new Date();
207
+ const thresholds = state.options.thresholds;
208
+ // Aggregate health
209
+ const aggObs = classifyRoute(state.aggregate, thresholds, now);
210
+ const aggExpr = (0, index_js_1.createExpression)(aggObs, [], 'aggregate');
211
+ // Determine overall status
212
+ const SEVERITY_MAP = {
213
+ [index_js_1.Health.INTACT]: 0, [index_js_1.Health.RECOVERING]: 1, [index_js_1.Health.DEGRADED]: 2,
214
+ [index_js_1.Health.ABLATED]: 3, [index_js_1.Health.OOD]: 4,
215
+ };
216
+ const worstHealth = aggObs.reduce((worst, o) => SEVERITY_MAP[o.health] > SEVERITY_MAP[worst] ? o.health : worst, index_js_1.Health.INTACT);
217
+ const response = {
218
+ status: worstHealth,
219
+ expression: (0, index_js_1.expressionToString)(aggExpr),
220
+ aggregate: (0, index_js_1.expressionToDict)(aggExpr),
221
+ };
222
+ // Per-route health
223
+ if (state.options.perRoute && state.routes.size > 0) {
224
+ const routes = {};
225
+ for (const [route, metrics] of state.routes) {
226
+ const obs = classifyRoute(metrics, thresholds, now);
227
+ if (obs.length > 0) {
228
+ const expr = (0, index_js_1.createExpression)(obs, [], route);
229
+ routes[route] = (0, index_js_1.expressionToDict)(expr);
230
+ }
231
+ }
232
+ response.routes = routes;
233
+ }
234
+ res.json(response);
235
+ };
236
+ }
237
+ //# sourceMappingURL=express.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"express.js","sourceRoot":"","sources":["../../src/adapters/express.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Vitest/Jest test health reporter for margin.
3
+ *
4
+ * Classifies test suite health after every run: pass rate, duration,
5
+ * flake rate. The JS equivalent of margin's pytest plugin.
6
+ *
7
+ * Usage with vitest:
8
+ * // vitest.config.ts
9
+ * import { marginReporter } from 'margin-ts/adapters/vitest';
10
+ * export default { test: { reporters: ['default', marginReporter()] } };
11
+ *
12
+ * Or use standalone after collecting results:
13
+ * import { classifySuite } from 'margin-ts/adapters/vitest';
14
+ * const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
15
+ */
16
+ import { Thresholds, Expression } from '../index.js';
17
+ export interface SuiteThresholds {
18
+ passRate: Thresholds;
19
+ skipRate: Thresholds;
20
+ meanDurationMs: Thresholds;
21
+ totalDurationMs: Thresholds;
22
+ }
23
+ export declare const DEFAULT_SUITE_THRESHOLDS: SuiteThresholds;
24
+ export interface SuiteResults {
25
+ passed: number;
26
+ failed: number;
27
+ skipped: number;
28
+ durationMs: number;
29
+ testDurations?: number[];
30
+ }
31
+ export declare function classifySuite(results: SuiteResults, thresholds?: SuiteThresholds, label?: string): Expression;
32
+ /**
33
+ * Format suite health as a printable string.
34
+ */
35
+ export declare function suiteHealthString(results: SuiteResults, thresholds?: SuiteThresholds): string;
36
+ //# sourceMappingURL=vitest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest.d.ts","sourceRoot":"","sources":["../../src/adapters/vitest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAGL,UAAU,EAIV,UAAU,EAGX,MAAM,aAAa,CAAC;AAMrB,MAAM,WAAW,eAAe;IAC9B,QAAQ,EAAE,UAAU,CAAC;IACrB,QAAQ,EAAE,UAAU,CAAC;IACrB,cAAc,EAAE,UAAU,CAAC;IAC3B,eAAe,EAAE,UAAU,CAAC;CAC7B;AAED,eAAO,MAAM,wBAAwB,EAAE,eAKtC,CAAC;AAMF,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAC;CAC1B;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,eAA0C,EACtD,KAAK,SAAK,GACT,UAAU,CAiEZ;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,EAAE,UAAU,CAAC,EAAE,eAAe,GAAG,MAAM,CAQ7F"}
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * Vitest/Jest test health reporter for margin.
4
+ *
5
+ * Classifies test suite health after every run: pass rate, duration,
6
+ * flake rate. The JS equivalent of margin's pytest plugin.
7
+ *
8
+ * Usage with vitest:
9
+ * // vitest.config.ts
10
+ * import { marginReporter } from 'margin-ts/adapters/vitest';
11
+ * export default { test: { reporters: ['default', marginReporter()] } };
12
+ *
13
+ * Or use standalone after collecting results:
14
+ * import { classifySuite } from 'margin-ts/adapters/vitest';
15
+ * const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.DEFAULT_SUITE_THRESHOLDS = void 0;
19
+ exports.classifySuite = classifySuite;
20
+ exports.suiteHealthString = suiteHealthString;
21
+ const index_js_1 = require("../index.js");
22
+ exports.DEFAULT_SUITE_THRESHOLDS = {
23
+ passRate: (0, index_js_1.createThresholds)(0.95, 0.70, true), // higher is better
24
+ skipRate: (0, index_js_1.createThresholds)(0.05, 0.20, false), // lower is better
25
+ meanDurationMs: (0, index_js_1.createThresholds)(100, 1000, false), // lower is better
26
+ totalDurationMs: (0, index_js_1.createThresholds)(30000, 120000, false), // lower is better
27
+ };
28
+ function classifySuite(results, thresholds = exports.DEFAULT_SUITE_THRESHOLDS, label = '') {
29
+ const total = results.passed + results.failed + results.skipped;
30
+ if (total === 0)
31
+ return (0, index_js_1.createExpression)([], [], label);
32
+ const passRate = results.passed / total;
33
+ const skipRate = results.skipped / total;
34
+ const meanDuration = results.testDurations && results.testDurations.length > 0
35
+ ? results.testDurations.reduce((a, b) => a + b, 0) / results.testDurations.length
36
+ : results.durationMs / total;
37
+ const now = new Date();
38
+ const observations = [
39
+ {
40
+ name: 'pass_rate',
41
+ health: (0, index_js_1.classify)(passRate, index_js_1.Confidence.HIGH, thresholds.passRate),
42
+ value: passRate,
43
+ baseline: 1.0,
44
+ confidence: index_js_1.Confidence.HIGH,
45
+ higherIsBetter: true,
46
+ provenance: [],
47
+ measuredAt: now,
48
+ },
49
+ {
50
+ name: 'skip_rate',
51
+ health: (0, index_js_1.classify)(skipRate, index_js_1.Confidence.HIGH, thresholds.skipRate),
52
+ value: skipRate,
53
+ baseline: 0.0,
54
+ confidence: index_js_1.Confidence.HIGH,
55
+ higherIsBetter: false,
56
+ provenance: [],
57
+ measuredAt: now,
58
+ },
59
+ {
60
+ name: 'mean_duration',
61
+ health: (0, index_js_1.classify)(meanDuration, index_js_1.Confidence.HIGH, thresholds.meanDurationMs),
62
+ value: meanDuration,
63
+ baseline: 50,
64
+ confidence: index_js_1.Confidence.HIGH,
65
+ higherIsBetter: false,
66
+ provenance: [],
67
+ measuredAt: now,
68
+ },
69
+ {
70
+ name: 'total_duration',
71
+ health: (0, index_js_1.classify)(results.durationMs, index_js_1.Confidence.MODERATE, thresholds.totalDurationMs),
72
+ value: results.durationMs,
73
+ baseline: 10000,
74
+ confidence: index_js_1.Confidence.MODERATE,
75
+ higherIsBetter: false,
76
+ provenance: [],
77
+ measuredAt: now,
78
+ },
79
+ {
80
+ name: 'failures',
81
+ health: (0, index_js_1.classify)(results.failed, index_js_1.Confidence.HIGH, (0, index_js_1.createThresholds)(0, 5, false)),
82
+ value: results.failed,
83
+ baseline: 0,
84
+ confidence: index_js_1.Confidence.HIGH,
85
+ higherIsBetter: false,
86
+ provenance: [],
87
+ measuredAt: now,
88
+ },
89
+ ];
90
+ return (0, index_js_1.createExpression)(observations, [], label || `suite (${total} tests)`);
91
+ }
92
+ /**
93
+ * Format suite health as a printable string.
94
+ */
95
+ function suiteHealthString(results, thresholds) {
96
+ const expr = classifySuite(results, thresholds);
97
+ const total = results.passed + results.failed + results.skipped;
98
+ const lines = [
99
+ `margin health: ${total} tests (${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped)`,
100
+ ` ${(0, index_js_1.expressionToString)(expr)}`,
101
+ ];
102
+ return lines.join('\n');
103
+ }
104
+ //# sourceMappingURL=vitest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vitest.js","sourceRoot":"","sources":["../../src/adapters/vitest.ts"],"names":[],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -11,4 +11,6 @@ export { Health, SEVERITY, Thresholds, createThresholds, isIntact, isAblated, cl
11
11
  export { Op, Observation, Correction, Expression, observationSigma, observationToAtom, observationToDict, observationFromDict, correctionIsActive, createExpression, healthOf, degraded, expressionToString, expressionToDict, Parser, } from './observation.js';
12
12
  export { DriftState, DriftDirection, DriftClassification, classifyDrift, } from './drift.js';
13
13
  export { AnomalyState, ANOMALY_SEVERITY, AnomalyClassification, classifyAnomaly, } from './anomaly.js';
14
+ export { marginMiddleware, marginHealthRoute, DEFAULT_THRESHOLDS as EXPRESS_THRESHOLDS, type MarginMiddlewareOptions, type MarginState, type EndpointThresholds, } from './adapters/express.js';
15
+ export { classifySuite, suiteHealthString, DEFAULT_SUITE_THRESHOLDS, type SuiteResults, type SuiteThresholds, } from './adapters/vitest.js';
14
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEzG,OAAO,EACL,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAC9C,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAC9B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EACvC,gBAAgB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAC3E,kBAAkB,EAClB,gBAAgB,EAAE,QAAQ,EAAE,QAAQ,EAAE,kBAAkB,EAAE,gBAAgB,EAC1E,MAAM,GACP,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EAAE,cAAc,EAAE,mBAAmB,EAC/C,aAAa,GACd,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EACrD,eAAe,GAChB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,aAAa,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEzG,OAAO,EACL,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,gBAAgB,EAC9C,QAAQ,EAAE,SAAS,EAAE,QAAQ,GAC9B,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,UAAU,EACvC,gBAAgB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAC3E,kBAAkB,EAClB,gBAAgB,EAAE,QAAQ,EAAE,QAAQ,EAAE,kBAAkB,EAAE,gBAAgB,EAC1E,MAAM,GACP,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EACL,UAAU,EAAE,cAAc,EAAE,mBAAmB,EAC/C,aAAa,GACd,MAAM,YAAY,CAAC;AAEpB,OAAO,EACL,YAAY,EAAE,gBAAgB,EAAE,qBAAqB,EACrD,eAAe,GAChB,MAAM,cAAc,CAAC;AAGtB,OAAO,EACL,gBAAgB,EAAE,iBAAiB,EACnC,kBAAkB,IAAI,kBAAkB,EACxC,KAAK,uBAAuB,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,GACxE,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EACL,aAAa,EAAE,iBAAiB,EAChC,wBAAwB,EACxB,KAAK,YAAY,EAAE,KAAK,eAAe,GACxC,MAAM,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * Copyright (c) 2026 Cope Labs LLC. MIT License.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.classifyAnomaly = exports.ANOMALY_SEVERITY = exports.AnomalyState = exports.classifyDrift = exports.DriftDirection = exports.DriftState = exports.Parser = exports.expressionToDict = exports.expressionToString = exports.degraded = exports.healthOf = exports.createExpression = exports.correctionIsActive = exports.observationFromDict = exports.observationToDict = exports.observationToAtom = exports.observationSigma = exports.Op = exports.classify = exports.isAblated = exports.isIntact = exports.createThresholds = exports.SEVERITY = exports.Health = exports.minConfidence = exports.confidenceLt = exports.confidenceGte = exports.confidenceRank = exports.Confidence = void 0;
11
+ exports.DEFAULT_SUITE_THRESHOLDS = exports.suiteHealthString = exports.classifySuite = exports.EXPRESS_THRESHOLDS = exports.marginHealthRoute = exports.marginMiddleware = exports.classifyAnomaly = exports.ANOMALY_SEVERITY = exports.AnomalyState = exports.classifyDrift = exports.DriftDirection = exports.DriftState = exports.Parser = exports.expressionToDict = exports.expressionToString = exports.degraded = exports.healthOf = exports.createExpression = exports.correctionIsActive = exports.observationFromDict = exports.observationToDict = exports.observationToAtom = exports.observationSigma = exports.Op = exports.classify = exports.isAblated = exports.isIntact = exports.createThresholds = exports.SEVERITY = exports.Health = exports.minConfidence = exports.confidenceLt = exports.confidenceGte = exports.confidenceRank = exports.Confidence = void 0;
12
12
  var confidence_js_1 = require("./confidence.js");
13
13
  Object.defineProperty(exports, "Confidence", { enumerable: true, get: function () { return confidence_js_1.Confidence; } });
14
14
  Object.defineProperty(exports, "confidenceRank", { enumerable: true, get: function () { return confidence_js_1.confidenceRank; } });
@@ -43,4 +43,13 @@ var anomaly_js_1 = require("./anomaly.js");
43
43
  Object.defineProperty(exports, "AnomalyState", { enumerable: true, get: function () { return anomaly_js_1.AnomalyState; } });
44
44
  Object.defineProperty(exports, "ANOMALY_SEVERITY", { enumerable: true, get: function () { return anomaly_js_1.ANOMALY_SEVERITY; } });
45
45
  Object.defineProperty(exports, "classifyAnomaly", { enumerable: true, get: function () { return anomaly_js_1.classifyAnomaly; } });
46
+ // Adapters
47
+ var express_js_1 = require("./adapters/express.js");
48
+ Object.defineProperty(exports, "marginMiddleware", { enumerable: true, get: function () { return express_js_1.marginMiddleware; } });
49
+ Object.defineProperty(exports, "marginHealthRoute", { enumerable: true, get: function () { return express_js_1.marginHealthRoute; } });
50
+ Object.defineProperty(exports, "EXPRESS_THRESHOLDS", { enumerable: true, get: function () { return express_js_1.DEFAULT_THRESHOLDS; } });
51
+ var vitest_js_1 = require("./adapters/vitest.js");
52
+ Object.defineProperty(exports, "classifySuite", { enumerable: true, get: function () { return vitest_js_1.classifySuite; } });
53
+ Object.defineProperty(exports, "suiteHealthString", { enumerable: true, get: function () { return vitest_js_1.suiteHealthString; } });
54
+ Object.defineProperty(exports, "DEFAULT_SUITE_THRESHOLDS", { enumerable: true, get: function () { return vitest_js_1.DEFAULT_SUITE_THRESHOLDS; } });
46
55
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;GAOG;;;AAEH,iDAAyG;AAAhG,2GAAA,UAAU,OAAA;AAAE,+GAAA,cAAc,OAAA;AAAE,8GAAA,aAAa,OAAA;AAAE,6GAAA,YAAY,OAAA;AAAE,8GAAA,aAAa,OAAA;AAE/E,yCAGqB;AAFnB,mGAAA,MAAM,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAAc,6GAAA,gBAAgB,OAAA;AAC9C,qGAAA,QAAQ,OAAA;AAAE,sGAAA,SAAS,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAG/B,mDAM0B;AALxB,oGAAA,EAAE,OAAA;AACF,kHAAA,gBAAgB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,qHAAA,mBAAmB,OAAA;AAC3E,oHAAA,kBAAkB,OAAA;AAClB,kHAAA,gBAAgB,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,oHAAA,kBAAkB,OAAA;AAAE,kHAAA,gBAAgB,OAAA;AAC1E,wGAAA,MAAM,OAAA;AAGR,uCAGoB;AAFlB,sGAAA,UAAU,OAAA;AAAE,0GAAA,cAAc,OAAA;AAC1B,yGAAA,aAAa,OAAA;AAGf,2CAGsB;AAFpB,0GAAA,YAAY,OAAA;AAAE,8GAAA,gBAAgB,OAAA;AAC9B,6GAAA,eAAe,OAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;GAOG;;;AAEH,iDAAyG;AAAhG,2GAAA,UAAU,OAAA;AAAE,+GAAA,cAAc,OAAA;AAAE,8GAAA,aAAa,OAAA;AAAE,6GAAA,YAAY,OAAA;AAAE,8GAAA,aAAa,OAAA;AAE/E,yCAGqB;AAFnB,mGAAA,MAAM,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAAc,6GAAA,gBAAgB,OAAA;AAC9C,qGAAA,QAAQ,OAAA;AAAE,sGAAA,SAAS,OAAA;AAAE,qGAAA,QAAQ,OAAA;AAG/B,mDAM0B;AALxB,oGAAA,EAAE,OAAA;AACF,kHAAA,gBAAgB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,mHAAA,iBAAiB,OAAA;AAAE,qHAAA,mBAAmB,OAAA;AAC3E,oHAAA,kBAAkB,OAAA;AAClB,kHAAA,gBAAgB,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,0GAAA,QAAQ,OAAA;AAAE,oHAAA,kBAAkB,OAAA;AAAE,kHAAA,gBAAgB,OAAA;AAC1E,wGAAA,MAAM,OAAA;AAGR,uCAGoB;AAFlB,sGAAA,UAAU,OAAA;AAAE,0GAAA,cAAc,OAAA;AAC1B,yGAAA,aAAa,OAAA;AAGf,2CAGsB;AAFpB,0GAAA,YAAY,OAAA;AAAE,8GAAA,gBAAgB,OAAA;AAC9B,6GAAA,eAAe,OAAA;AAGjB,WAAW;AACX,oDAI+B;AAH7B,8GAAA,gBAAgB,OAAA;AAAE,+GAAA,iBAAiB,OAAA;AACnC,gHAAA,kBAAkB,OAAsB;AAI1C,kDAI8B;AAH5B,0GAAA,aAAa,OAAA;AAAE,8GAAA,iBAAiB,OAAA;AAChC,qHAAA,wBAAwB,OAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "margin-ts",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Typed health classification for systems that measure things. Zero dependencies.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Express middleware adapter for margin.
3
+ *
4
+ * Tracks per-route health: latency, error rate, request rate.
5
+ * Exposes /margin/health endpoint with typed classifications.
6
+ *
7
+ * Usage:
8
+ * import express from 'express';
9
+ * import { marginMiddleware, marginHealthRoute } from 'margin-ts/adapters/express';
10
+ *
11
+ * const app = express();
12
+ * app.use(marginMiddleware());
13
+ * app.get('/margin/health', marginHealthRoute());
14
+ *
15
+ * Zero dependencies beyond margin-ts core (express types are optional).
16
+ */
17
+
18
+ import {
19
+ Confidence,
20
+ Health,
21
+ Thresholds,
22
+ createThresholds,
23
+ classify,
24
+ Observation,
25
+ Expression,
26
+ createExpression,
27
+ observationSigma,
28
+ expressionToString,
29
+ expressionToDict,
30
+ DriftState,
31
+ DriftDirection,
32
+ classifyDrift,
33
+ AnomalyState,
34
+ classifyAnomaly,
35
+ } from '../index.js';
36
+
37
+ // -----------------------------------------------------------------------
38
+ // Route metrics tracking
39
+ // -----------------------------------------------------------------------
40
+
41
+ interface RouteMetrics {
42
+ route: string;
43
+ totalRequests: number;
44
+ totalErrors: number; // status >= 500
45
+ total4xx: number; // status 400-499
46
+ latencies: number[]; // last N response times in ms
47
+ timestamps: Date[]; // when each request happened
48
+ maxWindow: number;
49
+ }
50
+
51
+ function createRouteMetrics(route: string, maxWindow = 200): RouteMetrics {
52
+ return {
53
+ route,
54
+ totalRequests: 0,
55
+ totalErrors: 0,
56
+ total4xx: 0,
57
+ latencies: [],
58
+ timestamps: [],
59
+ maxWindow,
60
+ };
61
+ }
62
+
63
+ function recordRequest(rm: RouteMetrics, latencyMs: number, statusCode: number): void {
64
+ rm.totalRequests++;
65
+ if (statusCode >= 500) rm.totalErrors++;
66
+ if (statusCode >= 400 && statusCode < 500) rm.total4xx++;
67
+ rm.latencies.push(latencyMs);
68
+ rm.timestamps.push(new Date());
69
+ if (rm.latencies.length > rm.maxWindow) {
70
+ rm.latencies.shift();
71
+ rm.timestamps.shift();
72
+ }
73
+ }
74
+
75
+ // -----------------------------------------------------------------------
76
+ // Default thresholds
77
+ // -----------------------------------------------------------------------
78
+
79
+ export interface EndpointThresholds {
80
+ p50Latency: Thresholds;
81
+ p99Latency: Thresholds;
82
+ errorRate: Thresholds;
83
+ requestRate: Thresholds;
84
+ }
85
+
86
+ export const DEFAULT_THRESHOLDS: EndpointThresholds = {
87
+ p50Latency: createThresholds(100, 500, false), // ms, lower is better
88
+ p99Latency: createThresholds(500, 2000, false), // ms, lower is better
89
+ errorRate: createThresholds(0.01, 0.10, false), // ratio, lower is better
90
+ requestRate: createThresholds(1.0, 0.1, true), // req/s, higher is better
91
+ };
92
+
93
+ // -----------------------------------------------------------------------
94
+ // Metrics → Observations
95
+ // -----------------------------------------------------------------------
96
+
97
+ function percentile(sorted: number[], p: number): number {
98
+ if (sorted.length === 0) return 0;
99
+ const idx = Math.ceil(sorted.length * p) - 1;
100
+ return sorted[Math.max(0, Math.min(idx, sorted.length - 1))];
101
+ }
102
+
103
+ function classifyRoute(
104
+ rm: RouteMetrics,
105
+ thresholds: EndpointThresholds,
106
+ now: Date,
107
+ ): Observation[] {
108
+ if (rm.latencies.length < 3) return [];
109
+
110
+ const sorted = [...rm.latencies].sort((a, b) => a - b);
111
+ const p50 = percentile(sorted, 0.5);
112
+ const p99 = percentile(sorted, 0.99);
113
+ const errorRate = rm.totalRequests > 0 ? rm.totalErrors / rm.totalRequests : 0;
114
+
115
+ // Request rate: requests in window / window duration
116
+ let requestRate = 0;
117
+ if (rm.timestamps.length >= 2) {
118
+ const windowMs = rm.timestamps[rm.timestamps.length - 1].getTime() - rm.timestamps[0].getTime();
119
+ if (windowMs > 0) requestRate = (rm.timestamps.length / windowMs) * 1000;
120
+ }
121
+
122
+ const prefix = rm.route === '*' ? '' : `${rm.route}:`;
123
+
124
+ const observations: Observation[] = [
125
+ {
126
+ name: `${prefix}p50_latency`,
127
+ health: classify(p50, Confidence.HIGH, thresholds.p50Latency),
128
+ value: p50,
129
+ baseline: thresholds.p50Latency.intact,
130
+ confidence: Confidence.HIGH,
131
+ higherIsBetter: false,
132
+ provenance: [],
133
+ measuredAt: now,
134
+ },
135
+ {
136
+ name: `${prefix}p99_latency`,
137
+ health: classify(p99, Confidence.HIGH, thresholds.p99Latency),
138
+ value: p99,
139
+ baseline: thresholds.p99Latency.intact,
140
+ confidence: Confidence.HIGH,
141
+ higherIsBetter: false,
142
+ provenance: [],
143
+ measuredAt: now,
144
+ },
145
+ {
146
+ name: `${prefix}error_rate`,
147
+ health: classify(errorRate, Confidence.HIGH, thresholds.errorRate),
148
+ value: errorRate,
149
+ baseline: 0.001,
150
+ confidence: Confidence.HIGH,
151
+ higherIsBetter: false,
152
+ provenance: [],
153
+ measuredAt: now,
154
+ },
155
+ {
156
+ name: `${prefix}request_rate`,
157
+ health: classify(requestRate, Confidence.MODERATE, thresholds.requestRate),
158
+ value: requestRate,
159
+ baseline: 10.0,
160
+ confidence: Confidence.MODERATE,
161
+ higherIsBetter: true,
162
+ provenance: [],
163
+ measuredAt: now,
164
+ },
165
+ ];
166
+
167
+ return observations;
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Middleware
172
+ // -----------------------------------------------------------------------
173
+
174
+ export interface MarginMiddlewareOptions {
175
+ /** Track per-route or aggregate only (default: true) */
176
+ perRoute?: boolean;
177
+ /** Max latency samples to keep per route (default: 200) */
178
+ window?: number;
179
+ /** Custom thresholds */
180
+ thresholds?: EndpointThresholds;
181
+ /** Routes to ignore (e.g. ['/margin/health', '/favicon.ico']) */
182
+ ignore?: string[];
183
+ /** Normalize route paths: replace numeric segments with :id (default: true) */
184
+ normalizePaths?: boolean;
185
+ }
186
+
187
+ /** The shared state object — accessible for custom endpoints */
188
+ export interface MarginState {
189
+ routes: Map<string, RouteMetrics>;
190
+ aggregate: RouteMetrics;
191
+ options: Required<MarginMiddlewareOptions>;
192
+ }
193
+
194
+ function normalizePath(path: string): string {
195
+ return path.replace(/\/\d+/g, '/:id').replace(/\/[0-9a-f]{24}/g, '/:id');
196
+ }
197
+
198
+ /**
199
+ * Express middleware that tracks request latency and error rates.
200
+ *
201
+ * Attach to your app before routes:
202
+ * app.use(marginMiddleware());
203
+ */
204
+ export function marginMiddleware(options: MarginMiddlewareOptions = {}): any {
205
+ const opts: Required<MarginMiddlewareOptions> = {
206
+ perRoute: options.perRoute ?? true,
207
+ window: options.window ?? 200,
208
+ thresholds: options.thresholds ?? DEFAULT_THRESHOLDS,
209
+ ignore: options.ignore ?? ['/margin/health', '/favicon.ico'],
210
+ normalizePaths: options.normalizePaths ?? true,
211
+ };
212
+
213
+ const state: MarginState = {
214
+ routes: new Map(),
215
+ aggregate: createRouteMetrics('*', opts.window),
216
+ options: opts,
217
+ };
218
+
219
+ const middleware = (req: any, res: any, next: any) => {
220
+ const path: string = req.path || req.url || '/';
221
+
222
+ if (opts.ignore.includes(path)) {
223
+ next();
224
+ return;
225
+ }
226
+
227
+ const start = Date.now();
228
+
229
+ // Hook into response finish
230
+ const onFinish = () => {
231
+ res.removeListener('finish', onFinish);
232
+ const latency = Date.now() - start;
233
+ const status: number = res.statusCode || 200;
234
+
235
+ // Aggregate
236
+ recordRequest(state.aggregate, latency, status);
237
+
238
+ // Per-route
239
+ if (opts.perRoute) {
240
+ const route = opts.normalizePaths ? normalizePath(path) : path;
241
+ if (!state.routes.has(route)) {
242
+ state.routes.set(route, createRouteMetrics(route, opts.window));
243
+ }
244
+ recordRequest(state.routes.get(route)!, latency, status);
245
+ }
246
+ };
247
+
248
+ res.on('finish', onFinish);
249
+ next();
250
+ };
251
+
252
+ // Attach state to middleware for access by health route
253
+ (middleware as any).__marginState = state;
254
+
255
+ return middleware;
256
+ }
257
+
258
+ // -----------------------------------------------------------------------
259
+ // Health endpoint
260
+ // -----------------------------------------------------------------------
261
+
262
+ export interface HealthResponse {
263
+ status: string;
264
+ expression: string;
265
+ aggregate: Record<string, unknown>;
266
+ routes?: Record<string, Record<string, unknown>>;
267
+ drift?: Record<string, unknown>;
268
+ }
269
+
270
+ /**
271
+ * Express route handler for /margin/health.
272
+ *
273
+ * Returns typed health classification for all tracked routes.
274
+ *
275
+ * app.get('/margin/health', marginHealthRoute());
276
+ *
277
+ * Or pass the middleware directly:
278
+ * const mw = marginMiddleware();
279
+ * app.use(mw);
280
+ * app.get('/margin/health', marginHealthRoute(mw));
281
+ */
282
+ export function marginHealthRoute(middleware?: any): any {
283
+ return (req: any, res: any) => {
284
+ // Find state from middleware
285
+ let state: MarginState | undefined;
286
+ if (middleware && (middleware as any).__marginState) {
287
+ state = (middleware as any).__marginState;
288
+ } else if (req.app) {
289
+ // Walk through app middleware stack to find ours
290
+ const stack = req.app._router?.stack || [];
291
+ for (const layer of stack) {
292
+ if (layer.handle?.__marginState) {
293
+ state = layer.handle.__marginState;
294
+ break;
295
+ }
296
+ }
297
+ }
298
+
299
+ if (!state) {
300
+ res.status(500).json({ error: 'margin middleware not found' });
301
+ return;
302
+ }
303
+
304
+ const now = new Date();
305
+ const thresholds = state.options.thresholds;
306
+
307
+ // Aggregate health
308
+ const aggObs = classifyRoute(state.aggregate, thresholds, now);
309
+ const aggExpr = createExpression(aggObs, [], 'aggregate');
310
+
311
+ // Determine overall status
312
+ const SEVERITY_MAP: Record<Health, number> = {
313
+ [Health.INTACT]: 0, [Health.RECOVERING]: 1, [Health.DEGRADED]: 2,
314
+ [Health.ABLATED]: 3, [Health.OOD]: 4,
315
+ };
316
+ const worstHealth = aggObs.reduce(
317
+ (worst, o) => SEVERITY_MAP[o.health] > SEVERITY_MAP[worst] ? o.health : worst,
318
+ Health.INTACT,
319
+ );
320
+
321
+ const response: HealthResponse = {
322
+ status: worstHealth,
323
+ expression: expressionToString(aggExpr),
324
+ aggregate: expressionToDict(aggExpr),
325
+ };
326
+
327
+ // Per-route health
328
+ if (state.options.perRoute && state.routes.size > 0) {
329
+ const routes: Record<string, Record<string, unknown>> = {};
330
+ for (const [route, metrics] of state.routes) {
331
+ const obs = classifyRoute(metrics, thresholds, now);
332
+ if (obs.length > 0) {
333
+ const expr = createExpression(obs, [], route);
334
+ routes[route] = expressionToDict(expr);
335
+ }
336
+ }
337
+ response.routes = routes;
338
+ }
339
+
340
+ res.json(response);
341
+ };
342
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Vitest/Jest test health reporter for margin.
3
+ *
4
+ * Classifies test suite health after every run: pass rate, duration,
5
+ * flake rate. The JS equivalent of margin's pytest plugin.
6
+ *
7
+ * Usage with vitest:
8
+ * // vitest.config.ts
9
+ * import { marginReporter } from 'margin-ts/adapters/vitest';
10
+ * export default { test: { reporters: ['default', marginReporter()] } };
11
+ *
12
+ * Or use standalone after collecting results:
13
+ * import { classifySuite } from 'margin-ts/adapters/vitest';
14
+ * const health = classifySuite({ passed: 95, failed: 3, skipped: 2, durationMs: 4500 });
15
+ */
16
+
17
+ import {
18
+ Confidence,
19
+ Health,
20
+ Thresholds,
21
+ createThresholds,
22
+ classify,
23
+ Observation,
24
+ Expression,
25
+ createExpression,
26
+ expressionToString,
27
+ } from '../index.js';
28
+
29
+ // -----------------------------------------------------------------------
30
+ // Suite metrics thresholds
31
+ // -----------------------------------------------------------------------
32
+
33
+ export interface SuiteThresholds {
34
+ passRate: Thresholds;
35
+ skipRate: Thresholds;
36
+ meanDurationMs: Thresholds;
37
+ totalDurationMs: Thresholds;
38
+ }
39
+
40
+ export const DEFAULT_SUITE_THRESHOLDS: SuiteThresholds = {
41
+ passRate: createThresholds(0.95, 0.70, true), // higher is better
42
+ skipRate: createThresholds(0.05, 0.20, false), // lower is better
43
+ meanDurationMs: createThresholds(100, 1000, false), // lower is better
44
+ totalDurationMs: createThresholds(30000, 120000, false), // lower is better
45
+ };
46
+
47
+ // -----------------------------------------------------------------------
48
+ // Suite results → Expression
49
+ // -----------------------------------------------------------------------
50
+
51
+ export interface SuiteResults {
52
+ passed: number;
53
+ failed: number;
54
+ skipped: number;
55
+ durationMs: number;
56
+ testDurations?: number[]; // per-test durations in ms
57
+ }
58
+
59
+ export function classifySuite(
60
+ results: SuiteResults,
61
+ thresholds: SuiteThresholds = DEFAULT_SUITE_THRESHOLDS,
62
+ label = '',
63
+ ): Expression {
64
+ const total = results.passed + results.failed + results.skipped;
65
+ if (total === 0) return createExpression([], [], label);
66
+
67
+ const passRate = results.passed / total;
68
+ const skipRate = results.skipped / total;
69
+ const meanDuration = results.testDurations && results.testDurations.length > 0
70
+ ? results.testDurations.reduce((a, b) => a + b, 0) / results.testDurations.length
71
+ : results.durationMs / total;
72
+
73
+ const now = new Date();
74
+ const observations: Observation[] = [
75
+ {
76
+ name: 'pass_rate',
77
+ health: classify(passRate, Confidence.HIGH, thresholds.passRate),
78
+ value: passRate,
79
+ baseline: 1.0,
80
+ confidence: Confidence.HIGH,
81
+ higherIsBetter: true,
82
+ provenance: [],
83
+ measuredAt: now,
84
+ },
85
+ {
86
+ name: 'skip_rate',
87
+ health: classify(skipRate, Confidence.HIGH, thresholds.skipRate),
88
+ value: skipRate,
89
+ baseline: 0.0,
90
+ confidence: Confidence.HIGH,
91
+ higherIsBetter: false,
92
+ provenance: [],
93
+ measuredAt: now,
94
+ },
95
+ {
96
+ name: 'mean_duration',
97
+ health: classify(meanDuration, Confidence.HIGH, thresholds.meanDurationMs),
98
+ value: meanDuration,
99
+ baseline: 50,
100
+ confidence: Confidence.HIGH,
101
+ higherIsBetter: false,
102
+ provenance: [],
103
+ measuredAt: now,
104
+ },
105
+ {
106
+ name: 'total_duration',
107
+ health: classify(results.durationMs, Confidence.MODERATE, thresholds.totalDurationMs),
108
+ value: results.durationMs,
109
+ baseline: 10000,
110
+ confidence: Confidence.MODERATE,
111
+ higherIsBetter: false,
112
+ provenance: [],
113
+ measuredAt: now,
114
+ },
115
+ {
116
+ name: 'failures',
117
+ health: classify(results.failed, Confidence.HIGH, createThresholds(0, 5, false)),
118
+ value: results.failed,
119
+ baseline: 0,
120
+ confidence: Confidence.HIGH,
121
+ higherIsBetter: false,
122
+ provenance: [],
123
+ measuredAt: now,
124
+ },
125
+ ];
126
+
127
+ return createExpression(observations, [], label || `suite (${total} tests)`);
128
+ }
129
+
130
+ /**
131
+ * Format suite health as a printable string.
132
+ */
133
+ export function suiteHealthString(results: SuiteResults, thresholds?: SuiteThresholds): string {
134
+ const expr = classifySuite(results, thresholds);
135
+ const total = results.passed + results.failed + results.skipped;
136
+ const lines = [
137
+ `margin health: ${total} tests (${results.passed} passed, ${results.failed} failed, ${results.skipped} skipped)`,
138
+ ` ${expressionToString(expr)}`,
139
+ ];
140
+ return lines.join('\n');
141
+ }
package/src/index.ts CHANGED
@@ -31,3 +31,16 @@ export {
31
31
  AnomalyState, ANOMALY_SEVERITY, AnomalyClassification,
32
32
  classifyAnomaly,
33
33
  } from './anomaly.js';
34
+
35
+ // Adapters
36
+ export {
37
+ marginMiddleware, marginHealthRoute,
38
+ DEFAULT_THRESHOLDS as EXPRESS_THRESHOLDS,
39
+ type MarginMiddlewareOptions, type MarginState, type EndpointThresholds,
40
+ } from './adapters/express.js';
41
+
42
+ export {
43
+ classifySuite, suiteHealthString,
44
+ DEFAULT_SUITE_THRESHOLDS,
45
+ type SuiteResults, type SuiteThresholds,
46
+ } from './adapters/vitest.js';