specshield 3.2.2 → 3.2.3

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,133 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Execute a set of conformance probes against the running provider.
5
+ * Pure function-ish: takes probes + an `http(method, url, opts)` adapter
6
+ * (so tests can inject without spinning a real HTTP client) and returns
7
+ * a structured result list.
8
+ *
9
+ * ProbeResult = {
10
+ * routePath: '/users/{userId}',
11
+ * method: 'GET',
12
+ * resolvedPath: '/users/u-7', // or null if skipped
13
+ * status: 'PASS' | 'FAIL' | 'SKIPPED' | 'ERROR',
14
+ * httpStatus: 200, // if reached
15
+ * reason: human-readable summary,
16
+ * mismatches: [{path, message, expected, got}, …], // only on FAIL
17
+ * skipReason: 'unresolved path params: paymentId', // only on SKIPPED
18
+ * error: 'ECONNREFUSED …', // only on ERROR
19
+ * }
20
+ *
21
+ * RunSummary = { total, pass, fail, skipped, error }
22
+ */
23
+
24
+ const { resolveProbePath, collectSpecExamples } = require('./pathResolver');
25
+ const { pickResponseSchema } = require('./probeBuilder');
26
+ const { validateBody } = require('./responseValidator');
27
+
28
+ /**
29
+ * @param spec dereferenced OAS (used to gather param examples)
30
+ * @param probes output of buildProbes(spec)
31
+ * @param opts.baseUrl e.g. 'https://staging.payments.acme.com'
32
+ * @param opts.pathParams { paymentId: 'pay-123', ... } CLI overrides
33
+ * @param opts.headers request headers to send (e.g. auth)
34
+ * @param opts.http async (method, url, {headers, timeoutMs}) → {status, body}
35
+ * @param opts.timeoutMs default 8000
36
+ */
37
+ async function runProbes(spec, probes, opts) {
38
+ const examples = collectSpecExamples(spec);
39
+ const results = [];
40
+ const http = opts.http;
41
+ const baseUrl = String(opts.baseUrl || '').replace(/\/$/, '');
42
+
43
+ for (const probe of probes) {
44
+ const { resolved, missing } = resolveProbePath(
45
+ probe.routePath, probe.method, examples, opts.pathParams,
46
+ );
47
+
48
+ if (missing.length > 0) {
49
+ results.push({
50
+ routePath: probe.routePath, method: probe.method,
51
+ resolvedPath: null, status: 'SKIPPED',
52
+ reason: `unresolved path params (no --path-params or spec example)`,
53
+ skipReason: missing.join(', '),
54
+ });
55
+ continue;
56
+ }
57
+
58
+ const url = baseUrl + resolved;
59
+ let httpStatus, body, err;
60
+ try {
61
+ const r = await http(probe.method, url, {
62
+ headers: opts.headers || {},
63
+ timeoutMs: opts.timeoutMs || 8000,
64
+ });
65
+ httpStatus = r.status;
66
+ body = r.body;
67
+ } catch (e) {
68
+ err = e.message || String(e);
69
+ }
70
+
71
+ if (err) {
72
+ results.push({
73
+ routePath: probe.routePath, method: probe.method,
74
+ resolvedPath: resolved, status: 'ERROR',
75
+ reason: 'HTTP call failed', error: err,
76
+ });
77
+ continue;
78
+ }
79
+
80
+ const schema = pickResponseSchema(probe, httpStatus);
81
+ if (schema === undefined) {
82
+ results.push({
83
+ routePath: probe.routePath, method: probe.method,
84
+ resolvedPath: resolved, status: 'FAIL', httpStatus,
85
+ reason: `actual status ${httpStatus} is not documented in the spec`,
86
+ mismatches: [],
87
+ });
88
+ continue;
89
+ }
90
+ if (schema === null) {
91
+ // Status documented but no JSON schema — accept any response body.
92
+ results.push({
93
+ routePath: probe.routePath, method: probe.method,
94
+ resolvedPath: resolved, status: 'PASS', httpStatus,
95
+ reason: 'status documented (no JSON schema to validate against)',
96
+ mismatches: [],
97
+ });
98
+ continue;
99
+ }
100
+
101
+ const { ok, errors } = validateBody(body, schema);
102
+ if (ok) {
103
+ results.push({
104
+ routePath: probe.routePath, method: probe.method,
105
+ resolvedPath: resolved, status: 'PASS', httpStatus,
106
+ reason: 'response matches spec schema',
107
+ mismatches: [],
108
+ });
109
+ } else {
110
+ results.push({
111
+ routePath: probe.routePath, method: probe.method,
112
+ resolvedPath: resolved, status: 'FAIL', httpStatus,
113
+ reason: `response body does not match spec (${errors.length} mismatch${errors.length === 1 ? '' : 'es'})`,
114
+ mismatches: errors,
115
+ });
116
+ }
117
+ }
118
+
119
+ return { results, summary: summarise(results) };
120
+ }
121
+
122
+ function summarise(results) {
123
+ const s = { total: results.length, pass: 0, fail: 0, skipped: 0, error: 0 };
124
+ for (const r of results) {
125
+ if (r.status === 'PASS') s.pass++;
126
+ else if (r.status === 'FAIL') s.fail++;
127
+ else if (r.status === 'SKIPPED') s.skipped++;
128
+ else if (r.status === 'ERROR') s.error++;
129
+ }
130
+ return s;
131
+ }
132
+
133
+ module.exports = { runProbes, summarise };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Group normalised HAR records by (method, templated-path) and emit an
5
+ * OpenAPI 3.0 document. Per-status response schemas are merged across all
6
+ * samples for that group so the emitted contract describes what the
7
+ * consumer ACTUALLY read across the captured run — not just one example.
8
+ */
9
+
10
+ const { templatePath } = require('./pathTemplate');
11
+ const { inferSchema, mergeSchemas } = require('./schemaInfer');
12
+
13
+ /**
14
+ * @param records normalised HAR records (output of normaliseEntries())
15
+ * @param opts { title, version, baseUrl, dynamicPatterns }
16
+ * @returns OpenAPI 3.0 document (plain JS object)
17
+ */
18
+ function harToOpenapi(records, opts = {}) {
19
+ const groups = new Map(); // key: "METHOD /templated/path"
20
+
21
+ for (const r of records) {
22
+ const { templated, paramNames } = templatePath(r.path, {
23
+ dynamicPatterns: opts.dynamicPatterns,
24
+ });
25
+ const key = `${r.method} ${templated}`;
26
+ let g = groups.get(key);
27
+ if (!g) {
28
+ g = {
29
+ method: r.method.toLowerCase(),
30
+ path: templated,
31
+ paramNames,
32
+ requestSchema: undefined,
33
+ responses: new Map(), // status → merged schema (or null = saw status, no JSON body)
34
+ };
35
+ groups.set(key, g);
36
+ }
37
+
38
+ if (r.requestBody !== undefined) {
39
+ const sample = inferSchema(r.requestBody);
40
+ g.requestSchema = g.requestSchema ? mergeSchemas(g.requestSchema, sample) : sample;
41
+ }
42
+
43
+ if (r.responseStatus) {
44
+ const status = String(r.responseStatus);
45
+ const schema = r.responseBody !== undefined ? inferSchema(r.responseBody) : null;
46
+ if (schema) {
47
+ const existing = g.responses.get(status);
48
+ g.responses.set(status, existing ? mergeSchemas(existing, schema) : schema);
49
+ } else if (!g.responses.has(status)) {
50
+ g.responses.set(status, null);
51
+ }
52
+ }
53
+ }
54
+
55
+ // Build the OpenAPI doc.
56
+ const paths = {};
57
+ for (const g of groups.values()) {
58
+ const pItem = paths[g.path] || (paths[g.path] = {});
59
+ const op = {};
60
+
61
+ if (g.paramNames.length > 0) {
62
+ op.parameters = g.paramNames.map(name => ({
63
+ name, in: 'path', required: true, schema: { type: 'string' },
64
+ }));
65
+ }
66
+
67
+ if (g.requestSchema) {
68
+ op.requestBody = {
69
+ required: true,
70
+ content: { 'application/json': { schema: g.requestSchema } },
71
+ };
72
+ }
73
+
74
+ const responses = {};
75
+ if (g.responses.size === 0) {
76
+ responses['200'] = { description: 'OK' };
77
+ } else {
78
+ // Sort numerically for stable output.
79
+ const statuses = [...g.responses.keys()].sort((a, b) => Number(a) - Number(b));
80
+ for (const status of statuses) {
81
+ const schema = g.responses.get(status);
82
+ const r = { description: defaultDescriptionFor(status) };
83
+ if (schema) r.content = { 'application/json': { schema } };
84
+ responses[status] = r;
85
+ }
86
+ }
87
+ op.responses = responses;
88
+
89
+ pItem[g.method] = op;
90
+ }
91
+
92
+ const doc = {
93
+ openapi: '3.0.0',
94
+ info: {
95
+ title: opts.title || 'Captured consumer contract',
96
+ version: opts.version || '0.1.0',
97
+ description:
98
+ 'Generated by `specshield bdct capture` from recorded HAR traffic. ' +
99
+ 'Reflects only the endpoints/fields the consumer actually called/read.',
100
+ },
101
+ paths: sortObject(paths), // stable, alphabetical output
102
+ };
103
+ if (opts.baseUrl) doc.servers = [{ url: String(opts.baseUrl) }];
104
+ // Re-order top-level so `servers` appears between `info` and `paths` (idiomatic).
105
+ if (opts.baseUrl) {
106
+ return { openapi: doc.openapi, info: doc.info, servers: doc.servers, paths: doc.paths };
107
+ }
108
+ return doc;
109
+ }
110
+
111
+ function defaultDescriptionFor(status) {
112
+ const n = Number(status);
113
+ if (n >= 200 && n < 300) return 'Successful response';
114
+ if (n >= 300 && n < 400) return 'Redirection';
115
+ if (n >= 400 && n < 500) return 'Client error';
116
+ if (n >= 500) return 'Server error';
117
+ return 'Response';
118
+ }
119
+
120
+ function sortObject(o) {
121
+ if (!o || typeof o !== 'object') return o;
122
+ return Object.keys(o).sort().reduce((acc, k) => { acc[k] = o[k]; return acc; }, {});
123
+ }
124
+
125
+ module.exports = { harToOpenapi };
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * One-call orchestrator for `specshield bdct capture from-har`. Read a HAR
5
+ * file → produce an OpenAPI 3.0 document (text + parsed) + a small summary
6
+ * the CLI can print.
7
+ *
8
+ * This is Phase 1 of Fix 2 (the BDCT fidelity-roadmap "capture" feature):
9
+ * HAR ingest specifically — language-agnostic, no TLS-proxy gymnastics.
10
+ * Later phases can layer on live capture (Node `--require` interceptor,
11
+ * full mitm proxy) without changing the downstream pipeline.
12
+ */
13
+
14
+ const yaml = require('js-yaml');
15
+ const { readHarFile, normaliseEntries } = require('./parseHar');
16
+ const { harToOpenapi } = require('./emitOpenapi');
17
+
18
+ /**
19
+ * @param filepath path to a HAR file
20
+ * @param opts { baseUrl, methods, onlyJson, title, version, format,
21
+ * dynamicPatterns }
22
+ * @returns { text, doc, summary }
23
+ *
24
+ * text — the OpenAPI document serialised (default: YAML)
25
+ * doc — the same document as a JS object
26
+ * summary — { harEntries, recordsKept, endpoints, operations }
27
+ */
28
+ function captureFromHarFile(filepath, opts = {}) {
29
+ const har = readHarFile(filepath);
30
+ const records = normaliseEntries(har, {
31
+ baseUrl: opts.baseUrl,
32
+ methods: opts.methods,
33
+ onlyJson: opts.onlyJson,
34
+ });
35
+ const doc = harToOpenapi(records, {
36
+ title: opts.title,
37
+ version: opts.version,
38
+ baseUrl: opts.baseUrl,
39
+ dynamicPatterns: opts.dynamicPatterns,
40
+ });
41
+
42
+ const fmt = String(opts.format || 'yaml').toLowerCase();
43
+ const text = fmt === 'json'
44
+ ? JSON.stringify(doc, null, 2) + '\n'
45
+ : yaml.dump(doc, { noRefs: true, sortKeys: false, lineWidth: 120 });
46
+
47
+ return {
48
+ text,
49
+ doc,
50
+ summary: {
51
+ harEntries: har.log.entries.length,
52
+ recordsKept: records.length,
53
+ endpoints: Object.keys(doc.paths || {}).length,
54
+ operations: countOperations(doc),
55
+ },
56
+ };
57
+ }
58
+
59
+ function countOperations(doc) {
60
+ let n = 0;
61
+ const methods = ['get','post','put','patch','delete','head','options','trace'];
62
+ for (const item of Object.values(doc.paths || {})) {
63
+ for (const k of Object.keys(item)) if (methods.includes(k)) n++;
64
+ }
65
+ return n;
66
+ }
67
+
68
+ module.exports = { captureFromHarFile };
@@ -0,0 +1,136 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Read + normalise a HAR (HTTP Archive 1.2) file into the minimal record
5
+ * shape the OpenAPI emitter consumes.
6
+ *
7
+ * readHarFile(path) → parsed HAR object (validates shape)
8
+ * normaliseEntries(har, opts) → HarRecord[]
9
+ *
10
+ * HarRecord = {
11
+ * method: 'GET' | 'POST' | …,
12
+ * url: URL object,
13
+ * path: '/users/123',
14
+ * query: { limit: '10', … },
15
+ * requestBody: parsed JSON | undefined,
16
+ * responseStatus: 200,
17
+ * responseBody: parsed JSON | undefined,
18
+ * requestContentType: 'application/json' | null,
19
+ * responseContentType: 'application/json' | null,
20
+ * }
21
+ */
22
+
23
+ const fs = require('fs');
24
+ const { URL } = require('url');
25
+
26
+ function isJsonMimeType(m) {
27
+ if (!m) return false;
28
+ return /\bjson\b/i.test(m);
29
+ }
30
+
31
+ function safeJsonParse(text) {
32
+ if (typeof text !== 'string' || text.trim().length === 0) return undefined;
33
+ try { return JSON.parse(text); } catch { return undefined; }
34
+ }
35
+
36
+ function readHarFile(filepath) {
37
+ const raw = fs.readFileSync(filepath, 'utf8');
38
+ let doc;
39
+ try { doc = JSON.parse(raw); }
40
+ catch (e) { throw new Error(`Not valid HAR JSON: ${filepath} (${e.message})`); }
41
+ if (!doc || typeof doc !== 'object' || !doc.log || !Array.isArray(doc.log.entries)) {
42
+ throw new Error(`Not a HAR file (missing log.entries): ${filepath}`);
43
+ }
44
+ return doc;
45
+ }
46
+
47
+ /**
48
+ * @param har parsed HAR object
49
+ * @param opts.baseUrl filter: keep only entries whose URL host (and optional
50
+ * prefix path, e.g. /v1) matches. Bare host accepted.
51
+ * @param opts.methods filter: keep only these HTTP methods (case-insensitive)
52
+ * @param opts.onlyJson default true — drop entries whose bodies are non-JSON
53
+ */
54
+ function normaliseEntries(har, opts = {}) {
55
+ const out = [];
56
+ const entries = har.log.entries;
57
+ const baseUrl = opts.baseUrl ? normaliseBase(opts.baseUrl) : null;
58
+ const methods = Array.isArray(opts.methods) && opts.methods.length > 0
59
+ ? new Set(opts.methods.map(m => m.toUpperCase()))
60
+ : null;
61
+ const onlyJson = opts.onlyJson !== false;
62
+
63
+ for (const e of entries) {
64
+ if (!e || !e.request) continue;
65
+ let url;
66
+ try { url = new URL(e.request.url); } catch { continue; }
67
+
68
+ const method = String(e.request.method || 'GET').toUpperCase();
69
+ if (methods && !methods.has(method)) continue;
70
+ if (baseUrl && !urlMatchesBase(url, baseUrl)) continue;
71
+
72
+ const reqMime = headerValue(e.request.headers, 'content-type');
73
+ const resMime = e.response && e.response.content && e.response.content.mimeType;
74
+
75
+ const requestBody = safeJsonParse(e.request.postData && e.request.postData.text);
76
+ const responseBody = safeJsonParse(e.response && e.response.content && e.response.content.text);
77
+
78
+ // Drop entries whose declared body is non-JSON (or undecodable). We can't
79
+ // infer a JSON Schema from binary/multipart/HTML, and silently emitting
80
+ // a wrong schema would be worse than dropping the sample.
81
+ if (onlyJson) {
82
+ const hasReqText = e.request.postData && typeof e.request.postData.text === 'string'
83
+ && e.request.postData.text.length > 0;
84
+ if (hasReqText && requestBody === undefined && !isJsonMimeType(reqMime)) continue;
85
+
86
+ const hasResText = e.response && e.response.content
87
+ && typeof e.response.content.text === 'string'
88
+ && e.response.content.text.length > 0;
89
+ if (hasResText && responseBody === undefined && !isJsonMimeType(resMime)) continue;
90
+ }
91
+
92
+ const query = {};
93
+ url.searchParams.forEach((v, k) => { query[k] = v; });
94
+
95
+ out.push({
96
+ method,
97
+ url,
98
+ path: url.pathname,
99
+ query,
100
+ requestBody,
101
+ responseStatus: e.response ? e.response.status : 0,
102
+ responseBody,
103
+ requestContentType: reqMime || null,
104
+ responseContentType: resMime || null,
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function headerValue(headers, name) {
111
+ if (!Array.isArray(headers)) return null;
112
+ const wanted = name.toLowerCase();
113
+ for (const h of headers) {
114
+ if (h && typeof h.name === 'string' && h.name.toLowerCase() === wanted) return h.value;
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function normaliseBase(base) {
120
+ if (!base) return null;
121
+ if (!/^https?:\/\//i.test(base)) base = `https://${base}`;
122
+ try { return new URL(base); } catch { return null; }
123
+ }
124
+
125
+ function urlMatchesBase(url, baseUrl) {
126
+ if (!baseUrl) return true;
127
+ if (url.host !== baseUrl.host) return false;
128
+ if (url.protocol !== baseUrl.protocol) return false;
129
+ const basePath = baseUrl.pathname.replace(/\/$/, '');
130
+ if (basePath && basePath !== '/' &&
131
+ !url.pathname.startsWith(basePath + '/') &&
132
+ url.pathname !== basePath) return false;
133
+ return true;
134
+ }
135
+
136
+ module.exports = { readHarFile, normaliseEntries, isJsonMimeType };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Path-template inference for HAR → OpenAPI capture (Fix 2 of the BDCT
5
+ * fidelity roadmap).
6
+ *
7
+ * Goal: turn concrete request paths recorded in real traffic into OpenAPI
8
+ * path templates, e.g.
9
+ * /users/123/orders/550e8400-e29b-41d4-a716-446655440000
10
+ * → /users/{userId}/orders/{orderId}
11
+ *
12
+ * Heuristic (intentionally conservative — false positives cost more than
13
+ * false negatives because over-templating destroys meaningful endpoint
14
+ * shape):
15
+ *
16
+ * A segment is "dynamic" iff it matches one of:
17
+ * - all digits e.g. "123", "42"
18
+ * - UUID e.g. "550e8400-…"
19
+ * - hex-only ≥ 8 chars e.g. "9a3f7c1b" (mongo-style ids)
20
+ * - has a digit AND a hyphen/underscore e.g. "PAY-2026-001"
21
+ *
22
+ * Pure-alpha segments ("orders", "summary", "profile") stay literal.
23
+ *
24
+ * Param naming: if the preceding segment is alpha (a resource name), the
25
+ * synthesised param is "<singular>Id" (rough singularise: strip trailing
26
+ * `s`), so /users/123 → /users/{userId}. Otherwise it's plain "id".
27
+ *
28
+ * Custom dynamic patterns can be supplied via `opts.dynamicPatterns` —
29
+ * an array of RegExp matched against each segment.
30
+ */
31
+
32
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+ const HEX_RE = /^[0-9a-f]{8,}$/i;
34
+ const DIGITS_RE = /^\d+$/;
35
+
36
+ function isDynamicSegment(seg, opts = {}) {
37
+ if (!seg) return false;
38
+ if (DIGITS_RE.test(seg)) return true;
39
+ if (UUID_RE.test(seg)) return true;
40
+ if (HEX_RE.test(seg)) return true;
41
+ if (/[-_]/.test(seg) && /\d/.test(seg)) return true;
42
+ if (Array.isArray(opts.dynamicPatterns)) {
43
+ for (const re of opts.dynamicPatterns) if (re.test(seg)) return true;
44
+ }
45
+ return false;
46
+ }
47
+
48
+ function paramNameFromContext(prevSegment) {
49
+ if (!prevSegment || !/^[a-z][a-z0-9_-]*$/i.test(prevSegment)) return 'id';
50
+ // Rough singularisation: users → user, orders → order, but "address" → "addres"
51
+ // is wrong. Only apply when the result still has >1 char AND the original
52
+ // ends in a plural-looking 's' (not 'ss' which is usually mass-noun).
53
+ const m = /^(.+?)s$/.exec(prevSegment);
54
+ const base = (m && !/ss$/.test(prevSegment)) ? m[1] : prevSegment;
55
+ if (!base || base.length < 2) return 'id';
56
+ return `${base}Id`;
57
+ }
58
+
59
+ /**
60
+ * Convert a concrete path (no query string) into a templated path.
61
+ * Returns { templated, paramNames }.
62
+ */
63
+ function templatePath(concretePath, opts = {}) {
64
+ if (typeof concretePath !== 'string' || concretePath.length === 0) {
65
+ return { templated: '/', paramNames: [] };
66
+ }
67
+ // Strip query if caller accidentally passed it.
68
+ const q = concretePath.indexOf('?');
69
+ const clean = q >= 0 ? concretePath.slice(0, q) : concretePath;
70
+
71
+ const segments = clean.split('/');
72
+ const paramNames = [];
73
+ // De-duplicate within one path so /users/1/users/2 → /users/{userId}/users/{userId2}
74
+ const used = new Map();
75
+
76
+ for (let i = 0; i < segments.length; i++) {
77
+ const s = segments[i];
78
+ if (!s) continue;
79
+ if (isDynamicSegment(s, opts)) {
80
+ let name = paramNameFromContext(segments[i - 1]);
81
+ const seen = used.get(name) || 0;
82
+ if (seen > 0) name = `${name}${seen + 1}`;
83
+ used.set(name.replace(/\d+$/, '') || name, seen + 1);
84
+ segments[i] = `{${name}}`;
85
+ paramNames.push(name);
86
+ }
87
+ }
88
+ const joined = segments.join('/');
89
+ return { templated: joined || '/', paramNames };
90
+ }
91
+
92
+ module.exports = { templatePath, isDynamicSegment, paramNameFromContext };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * JSON Schema inference from sample bodies — for HAR-ingest capture (Fix 2).
5
+ *
6
+ * inferSchema(value) → JSON Schema describing one sample
7
+ * mergeSchemas(a, b) → schema that admits both inputs
8
+ *
9
+ * The merger is the interesting bit: across multiple recorded responses
10
+ * for the same endpoint, fields seen in EVERY sample stay `required`;
11
+ * fields seen in only SOME become optional; type conflicts widen
12
+ * conservatively (integer + number → number; otherwise → string).
13
+ *
14
+ * Common string formats (uuid, date-time, email) are detected so the
15
+ * emitted OpenAPI subset is richer than just "type: string".
16
+ */
17
+
18
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
19
+ const DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
20
+ const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
21
+
22
+ function inferSchema(value) {
23
+ if (value === null || value === undefined) return { type: 'null' };
24
+ if (typeof value === 'boolean') return { type: 'boolean' };
25
+ if (typeof value === 'string') {
26
+ if (UUID_RE.test(value)) return { type: 'string', format: 'uuid' };
27
+ if (DATE_TIME_RE.test(value)) return { type: 'string', format: 'date-time' };
28
+ if (EMAIL_RE.test(value)) return { type: 'string', format: 'email' };
29
+ return { type: 'string' };
30
+ }
31
+ if (typeof value === 'number') {
32
+ return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
33
+ }
34
+ if (Array.isArray(value)) {
35
+ if (value.length === 0) return { type: 'array', items: {} };
36
+ let items = inferSchema(value[0]);
37
+ for (let i = 1; i < value.length; i++) {
38
+ items = mergeSchemas(items, inferSchema(value[i]));
39
+ }
40
+ return { type: 'array', items };
41
+ }
42
+ if (typeof value === 'object') {
43
+ const properties = {};
44
+ const required = [];
45
+ for (const [k, v] of Object.entries(value)) {
46
+ properties[k] = inferSchema(v);
47
+ // null values still register the key, but the field is not "required"
48
+ // (it was present-but-null; another sample might omit it entirely).
49
+ if (v !== null && v !== undefined) required.push(k);
50
+ }
51
+ const out = { type: 'object', properties };
52
+ if (required.length > 0) out.required = required;
53
+ return out;
54
+ }
55
+ return {};
56
+ }
57
+
58
+ function mergeSchemas(a, b) {
59
+ if (!a || Object.keys(a).length === 0) return b || {};
60
+ if (!b || Object.keys(b).length === 0) return a;
61
+ if (a.type === 'null') return b;
62
+ if (b.type === 'null') return a;
63
+
64
+ if (a.type === b.type) {
65
+ if (a.type === 'object') {
66
+ const out = { type: 'object', properties: {} };
67
+ const allKeys = new Set([
68
+ ...Object.keys(a.properties || {}),
69
+ ...Object.keys(b.properties || {}),
70
+ ]);
71
+ for (const k of allKeys) {
72
+ const aProp = a.properties && a.properties[k];
73
+ const bProp = b.properties && b.properties[k];
74
+ out.properties[k] = aProp && bProp ? mergeSchemas(aProp, bProp) : (aProp || bProp);
75
+ }
76
+ // Required = INTERSECTION (a field is only "always present" if both samples had it).
77
+ const aReq = new Set(a.required || []);
78
+ const bReq = new Set(b.required || []);
79
+ const intersect = [...aReq].filter(k => bReq.has(k));
80
+ if (intersect.length > 0) out.required = intersect;
81
+ return out;
82
+ }
83
+ if (a.type === 'array') {
84
+ return { type: 'array', items: mergeSchemas(a.items || {}, b.items || {}) };
85
+ }
86
+ // Same primitive type. Keep `format` only if both samples agreed.
87
+ const out = { type: a.type };
88
+ if (a.format && a.format === b.format) out.format = a.format;
89
+ return out;
90
+ }
91
+
92
+ // Type mismatch — widen numerically; otherwise fall back to string.
93
+ if ((a.type === 'integer' && b.type === 'number') ||
94
+ (a.type === 'number' && b.type === 'integer')) {
95
+ return { type: 'number' };
96
+ }
97
+ return { type: 'string' };
98
+ }
99
+
100
+ module.exports = { inferSchema, mergeSchemas };