specdrift-cli 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Bridges
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # specdrift
2
+
3
+ Point it at a live API and its own OpenAPI spec. It tells you where they've
4
+ drifted apart. Free, zero-dependency, MIT-licensed, GET-only.
5
+
6
+ ## What it does
7
+
8
+ For every `GET` operation in your OpenAPI 3.x spec, specdrift:
9
+
10
+ 1. builds a real request from the spec's own declared parameter
11
+ examples/defaults (path and query params — it never guesses a value that
12
+ isn't in the spec),
13
+ 2. calls your live API,
14
+ 3. compares the actual response against the spec: status code declared?
15
+ required fields present? field types matching (`string`/`number`/
16
+ `integer`/`boolean`/`array`/`object`)? any undeclared extra fields?
17
+
18
+ ...and prints a plain-text report. Nothing is mutated, nothing is stored,
19
+ nothing is sent anywhere except your own API.
20
+
21
+ **Safety note:** specdrift only ever sends `GET` requests. It will not call
22
+ `POST`/`PUT`/`PATCH`/`DELETE` on your API, even if your spec declares them —
23
+ running an unattended tool against arbitrary mutating endpoints on someone's
24
+ live system is a real risk, not a hypothetical one, so v1 deliberately
25
+ doesn't do it.
26
+
27
+ ## Usage
28
+
29
+ Not yet published to npm, so for now, clone and run directly:
30
+
31
+ ```bash
32
+ git clone https://github.com/MattBridges/specdrift.git
33
+ cd specdrift/cli # if running from this monorepo path, otherwise cli/ is the root
34
+ node bin/specdrift.js path/to/openapi.json https://api.example.com
35
+ ```
36
+
37
+ ```
38
+ specdrift <spec.json> <baseUrl> [--path <regex>] [--timeout <ms>]
39
+ ```
40
+
41
+ - `<spec.json>` — a local path or URL to an OpenAPI 3.x **JSON** document
42
+ (YAML is a known v1 gap — see below, it fails loudly rather than silently
43
+ misparsing).
44
+ - `<baseUrl>` — the live API to check.
45
+ - `--path <regex>` — only check paths matching this regex.
46
+ - `--timeout <ms>` — per-request timeout, default 10000.
47
+
48
+ Exit codes: `0` = no drift found, `1` = drift found in at least one
49
+ endpoint, `2` = specdrift itself couldn't run (bad spec, no checkable
50
+ endpoints, etc.) — matches the convention used across United Front Labs'
51
+ other free CLIs (see
52
+ [d1-migration-guard](https://github.com/MattBridges/d1-migration-guard)) so
53
+ CI usage (`specdrift ... || exit 1`) is predictable across tools.
54
+
55
+ Requires Node.js >= 18 (uses the built-in `fetch`). Zero npm dependencies.
56
+
57
+ ## Example
58
+
59
+ ```
60
+ $ specdrift openapi.json https://api.example.com
61
+
62
+ specdrift: checking 3 GET endpoint(s) against https://api.example.com
63
+
64
+ OK GET /widgets -- 200 matches spec (declared: 200)
65
+ DRIFT GET /widgets/{id} -- 200 (matched "200")
66
+ $.price: type mismatch -- spec declares "number", actual response has "string"
67
+ info $.sku: field present in actual response but not declared in spec
68
+ SKIP GET /orders/{id} -- path parameter "id" has no example/default value to substitute
69
+
70
+ specdrift: 2 checked, 1 with drift, 1 skipped, 0 request error(s).
71
+ ```
72
+
73
+ ## Status: v0.1, verified against real behavior
74
+
75
+ Verified end-to-end with a local fixture HTTP server (`test/fixtures/`) run
76
+ under a real Node process, in two modes: a spec-conformant server (expect
77
+ exit 0, zero findings) and a deliberately drifted one (missing required
78
+ field, extra undeclared field, wrong field type, and an undeclared status
79
+ code — all four correctly detected with the right exit code). Not tested
80
+ against every real-world OpenAPI dialect quirk (e.g. `oneOf`/`anyOf`/
81
+ `allOf`, `nullable`, deep array recursion beyond the first item) — those are
82
+ either skipped safely (no false "drift") or a documented gap, not silently
83
+ mishandled.
84
+
85
+ ## Known v1 limitations (documented, not hidden)
86
+
87
+ - OpenAPI JSON only — no YAML parsing (adding a real YAML parser without a
88
+ dependency is nontrivial; a "known gap" beats a buggy hand-rolled parser).
89
+ - GET-only — no coverage of mutating operations, by design (see Safety note
90
+ above).
91
+ - Path/query parameters need an `example`, `schema.example`,
92
+ `schema.default`, or `schema.enum` in the spec to be tested; parameters
93
+ with none of those are skipped and reported as `SKIP`, not silently
94
+ dropped or guessed.
95
+ - Object/array schema comparison recurses up to 3 levels deep and checks
96
+ only the first item of an array — enough to catch the drift that actually
97
+ happens in practice (renamed/removed/retyped fields), not a full JSON
98
+ Schema validator.
99
+ - `oneOf`/`anyOf`/`allOf`/`nullable` and other advanced JSON Schema
100
+ keywords are not evaluated (no false positives — those fields are simply
101
+ not checked, and this is expected v1 behavior, not a hidden bug).
102
+
103
+ ## Why this exists
104
+
105
+ This CLI is a free companion to [SpecDrift](https://mattbridges.github.io/specdrift/),
106
+ a hosted contract-drift monitor concept (continuous checks + AI-written
107
+ plain-language change summaries posted to Slack/PR comments). The free CLI
108
+ gives you a real, usable one-shot check today with zero signup; the hosted
109
+ version (still pre-launch, [waitlist here](https://mattbridges.github.io/specdrift/))
110
+ would run this continuously and explain *why* drift matters, not just that
111
+ it happened.
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { loadSpec } = require('../src/spec');
5
+ const { listCheckableEndpoints, buildRequest } = require('../src/endpoints');
6
+ const { diffResponse } = require('../src/diff');
7
+
8
+ function printHelp() {
9
+ console.log(`specdrift -- check a live API against its own OpenAPI spec
10
+
11
+ Usage:
12
+ specdrift <spec.json> <baseUrl> [options]
13
+
14
+ <spec.json> Path or URL to an OpenAPI 3.x JSON document
15
+ <baseUrl> Base URL of the live API to check, e.g. https://api.example.com
16
+
17
+ Options:
18
+ --path <regex> Only check paths matching this regex
19
+ --timeout <ms> Per-request timeout in milliseconds (default 10000)
20
+ -h, --help Show this help
21
+
22
+ specdrift only sends GET requests. It never calls POST/PUT/PATCH/DELETE against
23
+ your live API. Exit codes: 0 = no drift found, 1 = drift found, 2 = could not run.
24
+
25
+ Docs: https://github.com/MattBridges/specdrift`);
26
+ }
27
+
28
+ function parseArgs(argv) {
29
+ const args = { _: [] };
30
+ for (let i = 0; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === '-h' || a === '--help') args.help = true;
33
+ else if (a === '--path') args.pathFilter = argv[++i];
34
+ else if (a === '--timeout') args.timeout = Number(argv[++i]);
35
+ else args._.push(a);
36
+ }
37
+ return args;
38
+ }
39
+
40
+ async function fetchWithTimeout(url, timeoutMs) {
41
+ const controller = new AbortController();
42
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
43
+ try {
44
+ const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
45
+ const contentType = res.headers.get('content-type') || '';
46
+ let body = null;
47
+ if (contentType.includes('application/json')) {
48
+ try {
49
+ body = await res.json();
50
+ } catch {
51
+ body = null;
52
+ }
53
+ }
54
+ return { status: res.status, body };
55
+ } finally {
56
+ clearTimeout(timer);
57
+ }
58
+ }
59
+
60
+ async function main() {
61
+ const args = parseArgs(process.argv.slice(2));
62
+ if (args.help || args._.length < 2) {
63
+ printHelp();
64
+ process.exit(args.help ? 0 : 2);
65
+ }
66
+
67
+ const [specArg, baseUrlArg] = args._;
68
+ const timeout = Number.isFinite(args.timeout) && args.timeout > 0 ? args.timeout : 10000;
69
+ const baseUrl = baseUrlArg.replace(/\/+$/, '');
70
+
71
+ let spec;
72
+ try {
73
+ spec = await loadSpec(specArg);
74
+ } catch (err) {
75
+ console.error(`specdrift: ${err.message}`);
76
+ process.exit(2);
77
+ }
78
+
79
+ let endpoints = listCheckableEndpoints(spec);
80
+ if (args.pathFilter) {
81
+ const re = new RegExp(args.pathFilter);
82
+ endpoints = endpoints.filter((e) => re.test(e.pathTemplate));
83
+ }
84
+
85
+ if (endpoints.length === 0) {
86
+ console.error('specdrift: no checkable GET endpoints found (after filtering, if any).');
87
+ process.exit(2);
88
+ }
89
+
90
+ let driftCount = 0;
91
+ let checkedCount = 0;
92
+ let skippedCount = 0;
93
+ let errorCount = 0;
94
+
95
+ console.log(`specdrift: checking ${endpoints.length} GET endpoint(s) against ${baseUrl}\n`);
96
+
97
+ for (const endpoint of endpoints) {
98
+ const built = buildRequest(endpoint);
99
+ if (built.skipped) {
100
+ skippedCount++;
101
+ console.log(`SKIP ${endpoint.method} ${endpoint.pathTemplate} -- ${built.skipped}`);
102
+ continue;
103
+ }
104
+
105
+ const url = baseUrl + built.pathStr;
106
+ let response;
107
+ try {
108
+ response = await fetchWithTimeout(url, timeout);
109
+ } catch (err) {
110
+ errorCount++;
111
+ console.log(`ERROR ${endpoint.method} ${endpoint.pathTemplate} -- request failed: ${err.message}`);
112
+ continue;
113
+ }
114
+
115
+ checkedCount++;
116
+ const { findings, matchedStatus, declaredStatuses } = diffResponse(spec, endpoint, response.status, response.body);
117
+ const errors = findings.filter((f) => f.level === 'error');
118
+ const infos = findings.filter((f) => f.level === 'info');
119
+
120
+ if (errors.length === 0) {
121
+ console.log(`OK ${endpoint.method} ${endpoint.pathTemplate} -- ${response.status} matches spec (declared: ${declaredStatuses.join(', ')})`);
122
+ } else {
123
+ driftCount++;
124
+ console.log(`DRIFT ${endpoint.method} ${endpoint.pathTemplate} -- ${response.status}${matchedStatus ? ` (matched "${matchedStatus}")` : ''}`);
125
+ for (const f of errors) {
126
+ console.log(` ${f.path}: ${f.message}`);
127
+ }
128
+ }
129
+ for (const f of infos) {
130
+ console.log(` info ${f.path}: ${f.message}`);
131
+ }
132
+ }
133
+
134
+ console.log(
135
+ `\nspecdrift: ${checkedCount} checked, ${driftCount} with drift, ${skippedCount} skipped, ${errorCount} request error(s).`
136
+ );
137
+
138
+ if (errorCount > 0 && checkedCount === 0) process.exit(2);
139
+ process.exit(driftCount > 0 ? 1 : 0);
140
+ }
141
+
142
+ main().catch((err) => {
143
+ console.error(`specdrift: unexpected error -- ${err.stack || err.message}`);
144
+ process.exit(2);
145
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "specdrift-cli",
3
+ "version": "0.1.0",
4
+ "description": "Check a live API against its own OpenAPI spec and report drift: undocumented status codes, missing required fields, and type mismatches.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "specdrift": "./bin/specdrift.js"
8
+ },
9
+ "main": "./src/index.js",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/MattBridges/specdrift.git",
13
+ "directory": "cli"
14
+ },
15
+ "homepage": "https://github.com/MattBridges/specdrift/tree/main/cli",
16
+ "bugs": {
17
+ "url": "https://github.com/MattBridges/specdrift/issues"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "dependencies": {},
23
+ "keywords": [
24
+ "openapi",
25
+ "api",
26
+ "contract-testing",
27
+ "drift-detection",
28
+ "api-monitoring",
29
+ "devtools"
30
+ ]
31
+ }
package/src/diff.js ADDED
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const { resolveSchema } = require('./spec');
4
+
5
+ const JSON_TYPE_OF = (value) => {
6
+ if (value === null) return 'null';
7
+ if (Array.isArray(value)) return 'array';
8
+ return typeof value === 'number' ? 'number' : typeof value;
9
+ };
10
+
11
+ function typeMatches(declaredType, actualJsType, actualValue) {
12
+ if (declaredType === 'integer') {
13
+ return actualJsType === 'number' && Number.isInteger(actualValue);
14
+ }
15
+ if (declaredType === 'number') return actualJsType === 'number';
16
+ if (declaredType === 'string') return actualJsType === 'string';
17
+ if (declaredType === 'boolean') return actualJsType === 'boolean';
18
+ if (declaredType === 'array') return actualJsType === 'array';
19
+ if (declaredType === 'object') return actualJsType === 'object';
20
+ return true; // unknown/unspecified declared type -- nothing to compare against
21
+ }
22
+
23
+ // Walks one level of an object schema against an actual value and returns a
24
+ // flat list of drift findings for that node. Recurses into declared object
25
+ // properties; does not attempt full recursive array-item diffing beyond the
26
+ // first element, since that is where nearly all real-world contract drift
27
+ // (renamed/removed/retyped fields) actually shows up, and going further would
28
+ // mostly add noise for a v1 tool.
29
+ function diffValue(spec, schema, value, path, findings, depth = 0) {
30
+ const resolved = resolveSchema(spec, schema);
31
+ if (!resolved || typeof resolved !== 'object' || Object.keys(resolved).length === 0) {
32
+ return; // no usable schema to compare against
33
+ }
34
+
35
+ if (value === undefined) {
36
+ findings.push({ level: 'error', path, message: 'declared in spec response but missing from actual response' });
37
+ return;
38
+ }
39
+
40
+ const declaredType = resolved.type;
41
+ const actualJsType = JSON_TYPE_OF(value);
42
+ if (declaredType && !typeMatches(declaredType, actualJsType, value)) {
43
+ findings.push({
44
+ level: 'error',
45
+ path,
46
+ message: `type mismatch -- spec declares "${declaredType}", actual response has "${actualJsType}"`,
47
+ });
48
+ return;
49
+ }
50
+
51
+ if (declaredType === 'object' || (!declaredType && resolved.properties)) {
52
+ const props = resolved.properties || {};
53
+ const required = resolved.required || [];
54
+ const actualObj = value && typeof value === 'object' ? value : {};
55
+ for (const key of required) {
56
+ if (!(key in actualObj)) {
57
+ findings.push({ level: 'error', path: `${path}.${key}`, message: 'required field missing from actual response' });
58
+ }
59
+ }
60
+ if (depth < 3) {
61
+ for (const [key, subSchema] of Object.entries(props)) {
62
+ if (key in actualObj) {
63
+ diffValue(spec, subSchema, actualObj[key], `${path}.${key}`, findings, depth + 1);
64
+ }
65
+ }
66
+ }
67
+ if (resolved.additionalProperties === false) {
68
+ for (const key of Object.keys(actualObj)) {
69
+ if (!(key in props)) {
70
+ findings.push({
71
+ level: 'error',
72
+ path: `${path}.${key}`,
73
+ message: 'field present in actual response but not declared in spec, and spec forbids additional properties',
74
+ });
75
+ }
76
+ }
77
+ } else {
78
+ for (const key of Object.keys(actualObj)) {
79
+ if (!(key in props)) {
80
+ findings.push({
81
+ level: 'info',
82
+ path: `${path}.${key}`,
83
+ message: 'field present in actual response but not declared in spec',
84
+ });
85
+ }
86
+ }
87
+ }
88
+ } else if (declaredType === 'array' && resolved.items && Array.isArray(value) && value.length > 0) {
89
+ diffValue(spec, resolved.items, value[0], `${path}[0]`, findings, depth + 1);
90
+ }
91
+ }
92
+
93
+ // Compares one live HTTP response against the OpenAPI spec's declared responses
94
+ // for an operation. Returns { findings, matchedStatus, declaredStatuses }.
95
+ function diffResponse(spec, endpoint, actualStatus, actualBody) {
96
+ const findings = [];
97
+ const declaredStatuses = Object.keys(endpoint.responses);
98
+ const key = declaredStatuses.includes(String(actualStatus)) ? String(actualStatus) : 'default';
99
+ const matched = endpoint.responses[key];
100
+
101
+ if (!matched) {
102
+ findings.push({
103
+ level: 'error',
104
+ path: '(status)',
105
+ message: `actual response status ${actualStatus} is not declared in the spec (declared: ${declaredStatuses.join(', ') || 'none'})`,
106
+ });
107
+ return { findings, matchedStatus: null, declaredStatuses };
108
+ }
109
+
110
+ const jsonContent = matched.content && matched.content['application/json'];
111
+ if (jsonContent && jsonContent.schema) {
112
+ diffValue(spec, jsonContent.schema, actualBody, '$', findings);
113
+ }
114
+ return { findings, matchedStatus: key, declaredStatuses };
115
+ }
116
+
117
+ module.exports = { diffResponse };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const { resolveRef } = require('./spec');
4
+
5
+ const CHECKABLE_METHODS = ['get'];
6
+
7
+ // Only GET operations are exercised. specdrift never sends POST/PUT/PATCH/DELETE
8
+ // against a live target -- this is a safety check tool, not a load-testing or
9
+ // fuzzing tool, and calling mutating endpoints on someone's real API without
10
+ // consent would be a real-world harm, not a hypothetical one.
11
+ function listCheckableEndpoints(spec) {
12
+ const endpoints = [];
13
+ const paths = spec.paths || {};
14
+ for (const [pathTemplate, pathItem] of Object.entries(paths)) {
15
+ if (!pathItem || typeof pathItem !== 'object') continue;
16
+ const pathLevelParams = pathItem.parameters || [];
17
+ for (const method of CHECKABLE_METHODS) {
18
+ const op = pathItem[method];
19
+ if (!op) continue;
20
+ const params = [...pathLevelParams, ...(op.parameters || [])].map((p) =>
21
+ p.$ref ? resolveRef(spec, p.$ref) : p
22
+ );
23
+ endpoints.push({
24
+ pathTemplate,
25
+ method: method.toUpperCase(),
26
+ operationId: op.operationId || `${method.toUpperCase()} ${pathTemplate}`,
27
+ params,
28
+ responses: op.responses || {},
29
+ });
30
+ }
31
+ }
32
+ return endpoints;
33
+ }
34
+
35
+ // Builds a concrete request (path + query string) from an endpoint's declared
36
+ // parameters, using each parameter's `example` or `schema.default`/`schema.example`
37
+ // as the value. Returns { skipped: reason } if a required parameter has no
38
+ // usable example value -- specdrift will not guess (e.g. inventing a fake user ID),
39
+ // since a wrong guess could produce a misleading drift report.
40
+ function buildRequest(endpoint) {
41
+ let pathStr = endpoint.pathTemplate;
42
+ const query = [];
43
+ for (const param of endpoint.params) {
44
+ const value = exampleValueFor(param);
45
+ if (param.in === 'path') {
46
+ if (value === undefined) {
47
+ return { skipped: `path parameter "${param.name}" has no example/default value to substitute` };
48
+ }
49
+ pathStr = pathStr.replace(`{${param.name}}`, encodeURIComponent(String(value)));
50
+ } else if (param.in === 'query') {
51
+ if (value === undefined) {
52
+ if (param.required) {
53
+ return { skipped: `required query parameter "${param.name}" has no example/default value` };
54
+ }
55
+ continue;
56
+ }
57
+ query.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
58
+ }
59
+ }
60
+ const search = query.length ? `?${query.join('&')}` : '';
61
+ return { pathStr: pathStr + search };
62
+ }
63
+
64
+ function exampleValueFor(param) {
65
+ if (param.example !== undefined) return param.example;
66
+ const schema = param.schema || {};
67
+ if (schema.example !== undefined) return schema.example;
68
+ if (schema.default !== undefined) return schema.default;
69
+ if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];
70
+ return undefined;
71
+ }
72
+
73
+ module.exports = { listCheckableEndpoints, buildRequest };
package/src/spec.js ADDED
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const https = require('https');
5
+ const http = require('http');
6
+
7
+ function fetchText(url) {
8
+ const client = url.startsWith('https:') ? https : http;
9
+ return new Promise((resolve, reject) => {
10
+ client
11
+ .get(url, (res) => {
12
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
13
+ fetchText(res.headers.location).then(resolve, reject);
14
+ return;
15
+ }
16
+ if (res.statusCode !== 200) {
17
+ reject(new Error(`GET ${url} -> HTTP ${res.statusCode}`));
18
+ res.resume();
19
+ return;
20
+ }
21
+ let body = '';
22
+ res.setEncoding('utf8');
23
+ res.on('data', (chunk) => (body += chunk));
24
+ res.on('end', () => resolve(body));
25
+ })
26
+ .on('error', reject);
27
+ });
28
+ }
29
+
30
+ async function loadSpec(specPathOrUrl) {
31
+ let raw;
32
+ if (/^https?:\/\//i.test(specPathOrUrl)) {
33
+ raw = await fetchText(specPathOrUrl);
34
+ } else {
35
+ raw = fs.readFileSync(specPathOrUrl, 'utf8');
36
+ }
37
+ let spec;
38
+ try {
39
+ spec = JSON.parse(raw);
40
+ } catch (err) {
41
+ throw new Error(
42
+ `Could not parse "${specPathOrUrl}" as JSON. specdrift v0.1 reads OpenAPI JSON specs only ` +
43
+ `(YAML support is a known gap, not silently ignored -- see README). Parse error: ${err.message}`
44
+ );
45
+ }
46
+ if (!spec.openapi || !String(spec.openapi).startsWith('3.')) {
47
+ throw new Error(
48
+ `"${specPathOrUrl}" does not look like an OpenAPI 3.x document (missing/unsupported "openapi" field). ` +
49
+ 'specdrift v0.1 supports OpenAPI 3.x only.'
50
+ );
51
+ }
52
+ return spec;
53
+ }
54
+
55
+ function resolveRef(spec, ref) {
56
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) {
57
+ throw new Error(`Unsupported $ref (only local refs starting with "#/" are supported): ${ref}`);
58
+ }
59
+ const parts = ref.slice(2).split('/');
60
+ let node = spec;
61
+ for (const part of parts) {
62
+ if (node == null || !(part in node)) {
63
+ throw new Error(`Could not resolve $ref "${ref}" in spec`);
64
+ }
65
+ node = node[part];
66
+ }
67
+ return node;
68
+ }
69
+
70
+ function resolveSchema(spec, schema, seen = new Set()) {
71
+ if (schema && typeof schema === 'object' && schema.$ref) {
72
+ if (seen.has(schema.$ref)) {
73
+ return {};
74
+ }
75
+ seen.add(schema.$ref);
76
+ return resolveSchema(spec, resolveRef(spec, schema.$ref), seen);
77
+ }
78
+ return schema;
79
+ }
80
+
81
+ module.exports = { loadSpec, resolveRef, resolveSchema, fetchText };
@@ -0,0 +1,60 @@
1
+ {
2
+ "openapi": "3.0.3",
3
+ "info": { "title": "Fixture API", "version": "1.0.0" },
4
+ "paths": {
5
+ "/widgets": {
6
+ "get": {
7
+ "operationId": "listWidgets",
8
+ "responses": {
9
+ "200": {
10
+ "description": "ok",
11
+ "content": {
12
+ "application/json": {
13
+ "schema": {
14
+ "type": "array",
15
+ "items": { "$ref": "#/components/schemas/Widget" }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ }
22
+ },
23
+ "/widgets/{id}": {
24
+ "get": {
25
+ "operationId": "getWidget",
26
+ "parameters": [
27
+ {
28
+ "name": "id",
29
+ "in": "path",
30
+ "required": true,
31
+ "schema": { "type": "string", "example": "w1" }
32
+ }
33
+ ],
34
+ "responses": {
35
+ "200": {
36
+ "description": "ok",
37
+ "content": {
38
+ "application/json": { "schema": { "$ref": "#/components/schemas/Widget" } }
39
+ }
40
+ },
41
+ "404": { "description": "not found" }
42
+ }
43
+ }
44
+ }
45
+ },
46
+ "components": {
47
+ "schemas": {
48
+ "Widget": {
49
+ "type": "object",
50
+ "required": ["id", "name", "price"],
51
+ "properties": {
52
+ "id": { "type": "string" },
53
+ "name": { "type": "string" },
54
+ "price": { "type": "number" },
55
+ "inStock": { "type": "boolean" }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // Deterministic fixture server used to verify specdrift's drift detection
4
+ // end-to-end before publishing, the same way d1-migration-guard was verified
5
+ // against planted-good and planted-bad fixtures before its repo went public.
6
+ const http = require('http');
7
+
8
+ const mode = process.argv[2] || 'clean';
9
+ const port = Number(process.argv[3] || 4123);
10
+
11
+ function send(res, status, body) {
12
+ res.writeHead(status, { 'content-type': 'application/json' });
13
+ res.end(JSON.stringify(body));
14
+ }
15
+
16
+ const server = http.createServer((req, res) => {
17
+ const url = new URL(req.url, `http://localhost:${port}`);
18
+
19
+ if (url.pathname === '/widgets') {
20
+ if (mode === 'clean') {
21
+ return send(res, 200, [{ id: 'w1', name: 'Widget One', price: 9.99, inStock: true }]);
22
+ }
23
+ // drift: missing required "price", extra undeclared "sku"
24
+ return send(res, 200, [{ id: 'w1', name: 'Widget One', sku: 'SKU-1' }]);
25
+ }
26
+
27
+ if (url.pathname === '/widgets/w1') {
28
+ if (mode === 'clean') {
29
+ return send(res, 200, { id: 'w1', name: 'Widget One', price: 9.99, inStock: true });
30
+ }
31
+ // drift: price is now a string ("9.99") instead of a number, and status is
32
+ // 500 which the spec never declares for this operation
33
+ return send(res, 500, { id: 'w1', name: 'Widget One', price: '9.99' });
34
+ }
35
+
36
+ send(res, 404, { error: 'not found' });
37
+ });
38
+
39
+ server.listen(port, () => {
40
+ console.log(`fixture server (${mode}) listening on http://localhost:${port}`);
41
+ });