specshield 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/bin/specshield.js +4 -0
- package/package.json +37 -0
- package/src/cli.js +20 -0
- package/src/commands/compare.js +179 -0
- package/src/core/classifyChanges.js +104 -0
- package/src/core/configLoader.js +111 -0
- package/src/core/diffEngine.js +370 -0
- package/src/core/exitCode.js +19 -0
- package/src/core/loadSpec.js +31 -0
- package/src/core/normalizeSpec.js +149 -0
- package/src/core/outputFormatter.js +88 -0
- package/src/core/parseSpec.js +51 -0
- package/src/utils/logger.js +22 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Deepak Satyam
|
|
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,158 @@
|
|
|
1
|
+
# SpecShield CLI
|
|
2
|
+
|
|
3
|
+
> Compare OpenAPI specs and detect breaking changes — designed for CI/CD and local developer workflows.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Detect breaking changes, additions, and modifications between two OpenAPI specs
|
|
8
|
+
- Support YAML and JSON specs
|
|
9
|
+
- CI/CD-ready with exit code control (`--fail-on-breaking`)
|
|
10
|
+
- Config file support (`.specshield.yml`)
|
|
11
|
+
- JSON output for machine parsing
|
|
12
|
+
- Ignore list to suppress known changes
|
|
13
|
+
- Placeholder remote mode for future SaaS backend integration
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install -g specshield
|
|
19
|
+
# or use locally:
|
|
20
|
+
npm install && npm link
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Basic comparison
|
|
26
|
+
```bash
|
|
27
|
+
specshield compare base.yaml target.yaml
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Fail CI on breaking changes
|
|
31
|
+
```bash
|
|
32
|
+
specshield compare base.yaml target.yaml --fail-on-breaking
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### JSON output (for scripts/automation)
|
|
36
|
+
```bash
|
|
37
|
+
specshield compare base.yaml target.yaml --json
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Save results to file
|
|
41
|
+
```bash
|
|
42
|
+
specshield compare base.yaml target.yaml --output result.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Ignore specific changes
|
|
46
|
+
```bash
|
|
47
|
+
specshield compare base.yaml target.yaml --ignore "DELETE /users removed" --fail-on-breaking
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Use custom config
|
|
51
|
+
```bash
|
|
52
|
+
specshield compare base.yaml target.yaml --config ./configs/.specshield.yml
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Options
|
|
56
|
+
|
|
57
|
+
| Option | Description |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `--json` | Output machine-readable JSON |
|
|
60
|
+
| `--output <file>` | Save result to file |
|
|
61
|
+
| `--fail-on-breaking` | Exit 1 if breaking changes found |
|
|
62
|
+
| `--allow-breaking` | Override fail behavior |
|
|
63
|
+
| `--config <path>` | Path to `.specshield.yml` |
|
|
64
|
+
| `--ignore <change>` | Ignore a change string (repeatable) |
|
|
65
|
+
| `--severity <level>` | `info` / `warning` / `error` |
|
|
66
|
+
| `--remote-url <url>` | Remote API endpoint (future mode) |
|
|
67
|
+
| `--timeout <ms>` | Timeout for remote requests |
|
|
68
|
+
|
|
69
|
+
## Config File
|
|
70
|
+
|
|
71
|
+
Create `.specshield.yml` in your project root:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
allowBreakingChanges: false
|
|
75
|
+
failOnBreaking: true
|
|
76
|
+
|
|
77
|
+
ignore:
|
|
78
|
+
- "User.email removed"
|
|
79
|
+
- "/admin DELETE removed"
|
|
80
|
+
|
|
81
|
+
severity: error
|
|
82
|
+
|
|
83
|
+
remote:
|
|
84
|
+
enabled: false
|
|
85
|
+
url: "https://api.specshield.io/compare"
|
|
86
|
+
timeout: 10000
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
> CLI arguments always override config file values.
|
|
90
|
+
|
|
91
|
+
## Exit Codes
|
|
92
|
+
|
|
93
|
+
| Code | Meaning |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `0` | Success — no blocking issues |
|
|
96
|
+
| `1` | Breaking changes found and `--fail-on-breaking` active |
|
|
97
|
+
| `2` | Invalid input, config error, or runtime error |
|
|
98
|
+
|
|
99
|
+
## CI/CD — GitHub Actions
|
|
100
|
+
|
|
101
|
+
```yaml
|
|
102
|
+
name: API Contract Check
|
|
103
|
+
|
|
104
|
+
on:
|
|
105
|
+
pull_request:
|
|
106
|
+
branches: [main]
|
|
107
|
+
|
|
108
|
+
jobs:
|
|
109
|
+
check-api-contract:
|
|
110
|
+
runs-on: ubuntu-latest
|
|
111
|
+
steps:
|
|
112
|
+
- uses: actions/checkout@v4
|
|
113
|
+
with:
|
|
114
|
+
fetch-depth: 0
|
|
115
|
+
|
|
116
|
+
- uses: actions/setup-node@v4
|
|
117
|
+
with:
|
|
118
|
+
node-version: '20'
|
|
119
|
+
|
|
120
|
+
- run: npm install -g specshield
|
|
121
|
+
|
|
122
|
+
# Option A: spec files are committed to the repo
|
|
123
|
+
- name: Get base spec from main branch
|
|
124
|
+
run: git show origin/main:api/openapi.yaml > /tmp/base-spec.yaml
|
|
125
|
+
|
|
126
|
+
- name: Compare specs
|
|
127
|
+
run: |
|
|
128
|
+
specshield compare /tmp/base-spec.yaml api/openapi.yaml \
|
|
129
|
+
--fail-on-breaking \
|
|
130
|
+
--output spec-diff.json
|
|
131
|
+
|
|
132
|
+
- name: Upload diff report
|
|
133
|
+
if: always()
|
|
134
|
+
uses: actions/upload-artifact@v4
|
|
135
|
+
with:
|
|
136
|
+
name: spec-diff
|
|
137
|
+
path: spec-diff.json
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
> **Note:** Some projects generate their OpenAPI spec dynamically (e.g. from Spring Boot annotations, FastAPI, etc.) instead of storing a static file. In that case, add a build step before the compare step to generate the spec from your code.
|
|
141
|
+
|
|
142
|
+
## Running Locally
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm install
|
|
146
|
+
npm link
|
|
147
|
+
specshield compare fixtures/spec-v1.yaml fixtures/spec-v2.yaml --fail-on-breaking
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Running Tests
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm test
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT © Deepak Satyam
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "specshield",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to compare OpenAPI specs and detect breaking changes",
|
|
5
|
+
"main": "src/cli.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"specshield": "./bin/specshield.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/specshield.js",
|
|
11
|
+
"test": "jest --coverage",
|
|
12
|
+
"test:watch": "jest --watch",
|
|
13
|
+
"lint": "eslint src tests --ext .js"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["openapi", "api", "breaking-changes", "cli", "specshield"],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"axios": "^1.6.7",
|
|
20
|
+
"chalk": "^4.1.2",
|
|
21
|
+
"commander": "^12.0.0",
|
|
22
|
+
"fs-extra": "^11.2.0",
|
|
23
|
+
"js-yaml": "^4.1.0",
|
|
24
|
+
"ora": "^5.4.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"jest": "^29.7.0"
|
|
28
|
+
},
|
|
29
|
+
"jest": {
|
|
30
|
+
"testEnvironment": "node",
|
|
31
|
+
"testMatch": ["**/tests/**/*.test.js"],
|
|
32
|
+
"collectCoverageFrom": ["src/**/*.js"]
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const { version } = require('../package.json');
|
|
5
|
+
const compareCommand = require('./commands/compare');
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
8
|
+
|
|
9
|
+
program
|
|
10
|
+
.name('specshield')
|
|
11
|
+
.description('Compare OpenAPI specs and detect breaking changes')
|
|
12
|
+
.version(version);
|
|
13
|
+
|
|
14
|
+
program.addCommand(compareCommand);
|
|
15
|
+
|
|
16
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
17
|
+
const logger = require('./utils/logger');
|
|
18
|
+
logger.error(err.message);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { loadSpec } = require('../core/loadSpec');
|
|
8
|
+
const { parseSpec } = require('../core/parseSpec');
|
|
9
|
+
const { normalizeSpec } = require('../core/normalizeSpec');
|
|
10
|
+
const { diffSpecs } = require('../core/diffEngine');
|
|
11
|
+
const { classifyChanges, filterBySeverity } = require('../core/classifyChanges');
|
|
12
|
+
const { formatHuman, formatJson } = require('../core/outputFormatter');
|
|
13
|
+
const { loadConfig } = require('../core/configLoader');
|
|
14
|
+
const { resolveExitCode } = require('../core/exitCode');
|
|
15
|
+
const logger = require('../utils/logger');
|
|
16
|
+
const fsExtra = require('fs-extra');
|
|
17
|
+
|
|
18
|
+
const compare = new Command('compare');
|
|
19
|
+
|
|
20
|
+
compare
|
|
21
|
+
.description('Compare two OpenAPI spec files and detect breaking changes')
|
|
22
|
+
.argument('<base>', 'Path to the base (old) OpenAPI spec')
|
|
23
|
+
.argument('<target>', 'Path to the target (new) OpenAPI spec')
|
|
24
|
+
.option('--json', 'Output machine-readable JSON')
|
|
25
|
+
.option('--output <file>', 'Save result to a file')
|
|
26
|
+
.option('--fail-on-breaking', 'Exit with code 1 if breaking changes are found')
|
|
27
|
+
.option('--allow-breaking', 'Override fail-on-breaking behavior')
|
|
28
|
+
.option('--config <path>', 'Path to .specshield.yml config file')
|
|
29
|
+
.option('--ignore <change>', 'Ignore a specific change string (repeatable)', collect, [])
|
|
30
|
+
.option('--severity <level>', 'Minimum severity level: info | warning | error', 'error')
|
|
31
|
+
.option('--remote-url <url>', 'Remote API endpoint for comparison')
|
|
32
|
+
.option('--timeout <ms>', 'Request timeout for remote mode (ms)', '10000')
|
|
33
|
+
.action(async (base, target, opts) => {
|
|
34
|
+
try {
|
|
35
|
+
// Load config
|
|
36
|
+
const config = await loadConfig(opts.config);
|
|
37
|
+
|
|
38
|
+
// Merge config with CLI options (CLI wins)
|
|
39
|
+
const options = mergeOptions(config, opts);
|
|
40
|
+
|
|
41
|
+
const spinner = options.json ? null : ora('Loading specs...').start();
|
|
42
|
+
|
|
43
|
+
let result;
|
|
44
|
+
|
|
45
|
+
if (options.remoteUrl || (config.remote && config.remote.enabled)) {
|
|
46
|
+
result = await runRemoteComparison(base, target, options, spinner);
|
|
47
|
+
} else {
|
|
48
|
+
result = await runLocalComparison(base, target, options, spinner);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (spinner) spinner.stop();
|
|
52
|
+
|
|
53
|
+
// Apply ignore list
|
|
54
|
+
result = applyIgnoreList(result, options.ignore || []);
|
|
55
|
+
|
|
56
|
+
// Apply severity filter
|
|
57
|
+
result = filterBySeverity(result, options.severity);
|
|
58
|
+
|
|
59
|
+
// Output
|
|
60
|
+
if (options.json) {
|
|
61
|
+
const jsonOutput = formatJson(result);
|
|
62
|
+
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
63
|
+
if (options.output) {
|
|
64
|
+
await fsExtra.outputFile(options.output, JSON.stringify(jsonOutput, null, 2));
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const humanOutput = formatHuman(result);
|
|
68
|
+
process.stdout.write(humanOutput + '\n');
|
|
69
|
+
if (options.output) {
|
|
70
|
+
const jsonOutput = formatJson(result);
|
|
71
|
+
await fsExtra.outputFile(options.output, JSON.stringify(jsonOutput, null, 2));
|
|
72
|
+
logger.info(`Results saved to ${options.output}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Exit code
|
|
77
|
+
const code = resolveExitCode(result, options);
|
|
78
|
+
process.exit(code);
|
|
79
|
+
|
|
80
|
+
} catch (err) {
|
|
81
|
+
logger.error(`Error: ${err.message}`);
|
|
82
|
+
process.exit(2);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
async function runLocalComparison(base, target, options, spinner) {
|
|
87
|
+
if (spinner) spinner.text = 'Loading base spec...';
|
|
88
|
+
const baseRaw = await loadSpec(base);
|
|
89
|
+
|
|
90
|
+
if (spinner) spinner.text = 'Loading target spec...';
|
|
91
|
+
const targetRaw = await loadSpec(target);
|
|
92
|
+
|
|
93
|
+
if (spinner) spinner.text = 'Parsing specs...';
|
|
94
|
+
const baseParsed = parseSpec(baseRaw, base);
|
|
95
|
+
const targetParsed = parseSpec(targetRaw, target);
|
|
96
|
+
|
|
97
|
+
if (spinner) spinner.text = 'Normalizing specs...';
|
|
98
|
+
const baseNorm = normalizeSpec(baseParsed);
|
|
99
|
+
const targetNorm = normalizeSpec(targetParsed);
|
|
100
|
+
|
|
101
|
+
if (spinner) spinner.text = 'Comparing specs...';
|
|
102
|
+
const rawDiffs = diffSpecs(baseNorm, targetNorm);
|
|
103
|
+
|
|
104
|
+
if (spinner) spinner.text = 'Classifying changes...';
|
|
105
|
+
return classifyChanges(rawDiffs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function runRemoteComparison(base, target, options, spinner) {
|
|
109
|
+
const axios = require('axios');
|
|
110
|
+
const { loadSpec } = require('../core/loadSpec');
|
|
111
|
+
|
|
112
|
+
if (spinner) spinner.text = 'Loading specs for remote comparison...';
|
|
113
|
+
const baseRaw = await loadSpec(base);
|
|
114
|
+
const targetRaw = await loadSpec(target);
|
|
115
|
+
|
|
116
|
+
const url = options.remoteUrl || (options.remote && options.remote.url);
|
|
117
|
+
const timeout = parseInt(options.timeout, 10) || 10000;
|
|
118
|
+
|
|
119
|
+
if (spinner) spinner.text = `Sending to remote: ${url}`;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await axios.post(
|
|
123
|
+
url,
|
|
124
|
+
{ baseSpec: baseRaw, targetSpec: targetRaw },
|
|
125
|
+
{ timeout, headers: { 'Content-Type': 'application/json' } }
|
|
126
|
+
);
|
|
127
|
+
return response.data;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const msg = err.response
|
|
130
|
+
? `Remote API error ${err.response.status}: ${JSON.stringify(err.response.data)}`
|
|
131
|
+
: `Remote connection failed: ${err.message}`;
|
|
132
|
+
throw new Error(msg);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function applyIgnoreList(result, ignoreList) {
|
|
137
|
+
if (!ignoreList || ignoreList.length === 0) return result;
|
|
138
|
+
|
|
139
|
+
const shouldIgnore = (change) => {
|
|
140
|
+
const desc = change.description || change.message || '';
|
|
141
|
+
return ignoreList.some((pattern) => desc.includes(pattern));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...result,
|
|
146
|
+
breakingChanges: result.breakingChanges.filter((c) => !shouldIgnore(c)),
|
|
147
|
+
additions: result.additions.filter((c) => !shouldIgnore(c)),
|
|
148
|
+
modifications: result.modifications.filter((c) => !shouldIgnore(c)),
|
|
149
|
+
warnings: result.warnings.filter((c) => !shouldIgnore(c)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function mergeOptions(config, cliOpts) {
|
|
154
|
+
return {
|
|
155
|
+
json: cliOpts.json || false,
|
|
156
|
+
output: cliOpts.output || null,
|
|
157
|
+
failOnBreaking: cliOpts.allowBreaking
|
|
158
|
+
? false
|
|
159
|
+
: cliOpts.failOnBreaking !== undefined
|
|
160
|
+
? cliOpts.failOnBreaking
|
|
161
|
+
: config.failOnBreaking !== undefined
|
|
162
|
+
? config.failOnBreaking
|
|
163
|
+
: false,
|
|
164
|
+
allowBreaking: cliOpts.allowBreaking || config.allowBreakingChanges || false,
|
|
165
|
+
ignore: [
|
|
166
|
+
...(cliOpts.ignore || []),
|
|
167
|
+
...(config.ignore || []),
|
|
168
|
+
],
|
|
169
|
+
severity: cliOpts.severity || config.severity || 'error',
|
|
170
|
+
remoteUrl: cliOpts.remoteUrl || (config.remote && config.remote.enabled ? config.remote.url : null),
|
|
171
|
+
timeout: cliOpts.timeout || (config.remote && config.remote.timeout) || 10000,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function collect(value, previous) {
|
|
176
|
+
return previous.concat([value]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = compare;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Classify raw diffs into breaking changes, additions, modifications, warnings.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const BREAKING_TYPES = new Set([
|
|
8
|
+
'ENDPOINT_REMOVED',
|
|
9
|
+
'METHOD_REMOVED',
|
|
10
|
+
'PARAMETER_REMOVED',
|
|
11
|
+
'PARAMETER_TYPE_CHANGED',
|
|
12
|
+
'PARAMETER_BECAME_REQUIRED',
|
|
13
|
+
'REQUEST_FIELD_REMOVED',
|
|
14
|
+
'REQUEST_FIELD_TYPE_CHANGED',
|
|
15
|
+
'REQUEST_REQUIRED_FIELD_ADDED',
|
|
16
|
+
'RESPONSE_FIELD_REMOVED',
|
|
17
|
+
'RESPONSE_FIELD_TYPE_CHANGED',
|
|
18
|
+
'RESPONSE_REMOVED',
|
|
19
|
+
'FIELD_BECAME_REQUIRED',
|
|
20
|
+
'ENUM_VALUE_REMOVED',
|
|
21
|
+
'REQUEST_TYPE_CHANGED',
|
|
22
|
+
'RESPONSE_TYPE_CHANGED',
|
|
23
|
+
'SCHEMA_REMOVED',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const ADDITION_TYPES = new Set([
|
|
27
|
+
'ENDPOINT_ADDED',
|
|
28
|
+
'METHOD_ADDED',
|
|
29
|
+
'PARAMETER_ADDED',
|
|
30
|
+
'REQUEST_FIELD_ADDED',
|
|
31
|
+
'RESPONSE_FIELD_ADDED',
|
|
32
|
+
'RESPONSE_ADDED',
|
|
33
|
+
'SCHEMA_ADDED',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const MODIFICATION_TYPES = new Set([
|
|
37
|
+
'FIELD_BECAME_OPTIONAL',
|
|
38
|
+
'PARAMETER_BECAME_OPTIONAL',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const WARNING_TYPES = new Set([
|
|
42
|
+
// future use
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// Numeric order: higher = more severe
|
|
46
|
+
const SEVERITY_ORDER = { error: 2, warning: 1, info: 0 };
|
|
47
|
+
|
|
48
|
+
function classifyChanges(diffs) {
|
|
49
|
+
const result = {
|
|
50
|
+
breakingChanges: [],
|
|
51
|
+
additions: [],
|
|
52
|
+
modifications: [],
|
|
53
|
+
warnings: [],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (const diff of diffs) {
|
|
57
|
+
const change = {
|
|
58
|
+
type: diff.type,
|
|
59
|
+
path: diff.path || null,
|
|
60
|
+
method: diff.method || null,
|
|
61
|
+
field: diff.field || null,
|
|
62
|
+
oldValue: diff.oldValue || null,
|
|
63
|
+
newValue: diff.newValue || null,
|
|
64
|
+
description: diff.description,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (BREAKING_TYPES.has(diff.type)) {
|
|
68
|
+
change.severity = 'error';
|
|
69
|
+
result.breakingChanges.push(change);
|
|
70
|
+
} else if (ADDITION_TYPES.has(diff.type)) {
|
|
71
|
+
change.severity = 'info';
|
|
72
|
+
result.additions.push(change);
|
|
73
|
+
} else if (MODIFICATION_TYPES.has(diff.type)) {
|
|
74
|
+
change.severity = 'warning';
|
|
75
|
+
result.modifications.push(change);
|
|
76
|
+
} else if (WARNING_TYPES.has(diff.type)) {
|
|
77
|
+
change.severity = 'warning';
|
|
78
|
+
result.warnings.push(change);
|
|
79
|
+
} else {
|
|
80
|
+
// Unknown type — treat as modification
|
|
81
|
+
change.severity = 'warning';
|
|
82
|
+
result.modifications.push(change);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Filter a classified result to only include changes at or above minSeverity.
|
|
91
|
+
* info < warning < error
|
|
92
|
+
*/
|
|
93
|
+
function filterBySeverity(result, minSeverity) {
|
|
94
|
+
const minLevel = SEVERITY_ORDER[minSeverity] ?? 0;
|
|
95
|
+
const passes = (c) => (SEVERITY_ORDER[c.severity] ?? 0) >= minLevel;
|
|
96
|
+
return {
|
|
97
|
+
breakingChanges: result.breakingChanges.filter(passes),
|
|
98
|
+
additions: result.additions.filter(passes),
|
|
99
|
+
modifications: result.modifications.filter(passes),
|
|
100
|
+
warnings: result.warnings.filter(passes),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { classifyChanges, filterBySeverity };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fsExtra = require('fs-extra');
|
|
4
|
+
const yaml = require('js-yaml');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const logger = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG_NAMES = ['.specshield.yml', '.specshield.yaml', '.specshield.json'];
|
|
9
|
+
|
|
10
|
+
const DEFAULT_CONFIG = {
|
|
11
|
+
allowBreakingChanges: false,
|
|
12
|
+
failOnBreaking: false,
|
|
13
|
+
ignore: [],
|
|
14
|
+
severity: 'info',
|
|
15
|
+
remote: {
|
|
16
|
+
enabled: false,
|
|
17
|
+
url: null,
|
|
18
|
+
timeout: 10000,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function loadConfig(configPath) {
|
|
23
|
+
// Explicit path provided
|
|
24
|
+
if (configPath) {
|
|
25
|
+
const resolved = path.resolve(configPath);
|
|
26
|
+
if (!(await fsExtra.pathExists(resolved))) {
|
|
27
|
+
throw new Error(`Config file not found: ${resolved}`);
|
|
28
|
+
}
|
|
29
|
+
return parseConfigFile(resolved);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Auto-discover in cwd
|
|
33
|
+
for (const name of DEFAULT_CONFIG_NAMES) {
|
|
34
|
+
const resolved = path.resolve(process.cwd(), name);
|
|
35
|
+
if (await fsExtra.pathExists(resolved)) {
|
|
36
|
+
logger.debug(`Using config: ${resolved}`);
|
|
37
|
+
return parseConfigFile(resolved);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { ...DEFAULT_CONFIG };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function parseConfigFile(filePath) {
|
|
45
|
+
const content = await fsExtra.readFile(filePath, 'utf8');
|
|
46
|
+
let parsed;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (filePath.endsWith('.json')) {
|
|
50
|
+
parsed = JSON.parse(content);
|
|
51
|
+
} else {
|
|
52
|
+
parsed = yaml.load(content);
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new Error(`Invalid config file "${filePath}": ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
59
|
+
throw new Error(`Config file "${filePath}" must be a YAML/JSON object`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return validateConfig(parsed, filePath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function validateConfig(config, filePath) {
|
|
66
|
+
const valid = { ...DEFAULT_CONFIG };
|
|
67
|
+
|
|
68
|
+
if (config.allowBreakingChanges !== undefined) {
|
|
69
|
+
if (typeof config.allowBreakingChanges !== 'boolean') {
|
|
70
|
+
throw new Error(`Config error in "${filePath}": allowBreakingChanges must be a boolean`);
|
|
71
|
+
}
|
|
72
|
+
valid.allowBreakingChanges = config.allowBreakingChanges;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (config.failOnBreaking !== undefined) {
|
|
76
|
+
if (typeof config.failOnBreaking !== 'boolean') {
|
|
77
|
+
throw new Error(`Config error in "${filePath}": failOnBreaking must be a boolean`);
|
|
78
|
+
}
|
|
79
|
+
valid.failOnBreaking = config.failOnBreaking;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (config.ignore !== undefined && config.ignore !== null) {
|
|
83
|
+
if (!Array.isArray(config.ignore)) {
|
|
84
|
+
throw new Error(`Config error in "${filePath}": ignore must be an array`);
|
|
85
|
+
}
|
|
86
|
+
valid.ignore = config.ignore.filter(Boolean);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (config.severity !== undefined) {
|
|
90
|
+
const allowed = ['info', 'warning', 'error'];
|
|
91
|
+
if (!allowed.includes(config.severity)) {
|
|
92
|
+
throw new Error(`Config error in "${filePath}": severity must be one of: ${allowed.join(', ')}`);
|
|
93
|
+
}
|
|
94
|
+
valid.severity = config.severity;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (config.remote !== undefined) {
|
|
98
|
+
if (typeof config.remote !== 'object') {
|
|
99
|
+
throw new Error(`Config error in "${filePath}": remote must be an object`);
|
|
100
|
+
}
|
|
101
|
+
valid.remote = {
|
|
102
|
+
enabled: Boolean(config.remote.enabled),
|
|
103
|
+
url: config.remote.url || null,
|
|
104
|
+
timeout: parseInt(config.remote.timeout, 10) || 10000,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return valid;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = { loadConfig };
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compare two normalized specs and return a flat list of raw diffs.
|
|
5
|
+
* Each diff: { type, path, method, field, oldValue, newValue, description }
|
|
6
|
+
*/
|
|
7
|
+
function diffSpecs(base, target) {
|
|
8
|
+
const diffs = [];
|
|
9
|
+
|
|
10
|
+
diffEndpoints(base.endpoints || {}, target.endpoints || {}, diffs);
|
|
11
|
+
diffSchemas(base.schemas || {}, target.schemas || {}, diffs);
|
|
12
|
+
|
|
13
|
+
return diffs;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ─── Endpoints ──────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function diffEndpoints(baseEndpoints, targetEndpoints, diffs) {
|
|
19
|
+
// Removed paths
|
|
20
|
+
for (const path of Object.keys(baseEndpoints)) {
|
|
21
|
+
if (!targetEndpoints[path]) {
|
|
22
|
+
for (const method of Object.keys(baseEndpoints[path])) {
|
|
23
|
+
diffs.push({
|
|
24
|
+
type: 'ENDPOINT_REMOVED',
|
|
25
|
+
path,
|
|
26
|
+
method,
|
|
27
|
+
description: `${method.toUpperCase()} ${path} was removed`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
diffMethods(path, baseEndpoints[path], targetEndpoints[path], diffs);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Added paths
|
|
36
|
+
for (const path of Object.keys(targetEndpoints)) {
|
|
37
|
+
if (!baseEndpoints[path]) {
|
|
38
|
+
for (const method of Object.keys(targetEndpoints[path])) {
|
|
39
|
+
diffs.push({
|
|
40
|
+
type: 'ENDPOINT_ADDED',
|
|
41
|
+
path,
|
|
42
|
+
method,
|
|
43
|
+
description: `${method.toUpperCase()} ${path} was added`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function diffMethods(path, baseMethods, targetMethods, diffs) {
|
|
51
|
+
for (const method of Object.keys(baseMethods)) {
|
|
52
|
+
if (!targetMethods[method]) {
|
|
53
|
+
diffs.push({
|
|
54
|
+
type: 'METHOD_REMOVED',
|
|
55
|
+
path,
|
|
56
|
+
method,
|
|
57
|
+
description: `${method.toUpperCase()} ${path} method was removed`,
|
|
58
|
+
});
|
|
59
|
+
} else {
|
|
60
|
+
diffOperation(path, method, baseMethods[method], targetMethods[method], diffs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const method of Object.keys(targetMethods)) {
|
|
65
|
+
if (!baseMethods[method]) {
|
|
66
|
+
diffs.push({
|
|
67
|
+
type: 'METHOD_ADDED',
|
|
68
|
+
path,
|
|
69
|
+
method,
|
|
70
|
+
description: `${method.toUpperCase()} ${path} method was added`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function diffOperation(path, method, baseOp, targetOp, diffs) {
|
|
77
|
+
diffParameters(path, method, baseOp.parameters || [], targetOp.parameters || [], diffs);
|
|
78
|
+
diffSchemaNode(path, method, 'requestBody', baseOp.requestBody, targetOp.requestBody, diffs, true);
|
|
79
|
+
diffResponses(path, method, baseOp.responses || {}, targetOp.responses || {}, diffs);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Parameters ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
function diffParameters(path, method, baseParams, targetParams, diffs) {
|
|
85
|
+
const baseMap = toParamMap(baseParams);
|
|
86
|
+
const targetMap = toParamMap(targetParams);
|
|
87
|
+
|
|
88
|
+
for (const key of Object.keys(baseMap)) {
|
|
89
|
+
if (!targetMap[key]) {
|
|
90
|
+
const p = baseMap[key];
|
|
91
|
+
diffs.push({
|
|
92
|
+
type: 'PARAMETER_REMOVED',
|
|
93
|
+
path,
|
|
94
|
+
method,
|
|
95
|
+
field: `parameters.${p.name}`,
|
|
96
|
+
description: `Parameter "${p.name}" (${p.in}) was removed from ${method.toUpperCase()} ${path}`,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
const bp = baseMap[key];
|
|
100
|
+
const tp = targetMap[key];
|
|
101
|
+
const baseType = schemaTypeStr(bp.schema);
|
|
102
|
+
const targetType = schemaTypeStr(tp.schema);
|
|
103
|
+
|
|
104
|
+
if (baseType !== targetType) {
|
|
105
|
+
diffs.push({
|
|
106
|
+
type: 'PARAMETER_TYPE_CHANGED',
|
|
107
|
+
path,
|
|
108
|
+
method,
|
|
109
|
+
field: `parameters.${bp.name}`,
|
|
110
|
+
oldValue: baseType,
|
|
111
|
+
newValue: targetType,
|
|
112
|
+
description: `Parameter "${bp.name}" type changed from "${baseType}" to "${targetType}" in ${method.toUpperCase()} ${path}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (bp.required !== tp.required) {
|
|
117
|
+
diffs.push({
|
|
118
|
+
type: tp.required ? 'PARAMETER_BECAME_REQUIRED' : 'PARAMETER_BECAME_OPTIONAL',
|
|
119
|
+
path,
|
|
120
|
+
method,
|
|
121
|
+
field: `parameters.${bp.name}`,
|
|
122
|
+
oldValue: String(bp.required),
|
|
123
|
+
newValue: String(tp.required),
|
|
124
|
+
description: `Parameter "${bp.name}" became ${tp.required ? 'required' : 'optional'} in ${method.toUpperCase()} ${path}`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const key of Object.keys(targetMap)) {
|
|
131
|
+
if (!baseMap[key]) {
|
|
132
|
+
const p = targetMap[key];
|
|
133
|
+
diffs.push({
|
|
134
|
+
type: 'PARAMETER_ADDED',
|
|
135
|
+
path,
|
|
136
|
+
method,
|
|
137
|
+
field: `parameters.${p.name}`,
|
|
138
|
+
description: `Parameter "${p.name}" (${p.in}) was added to ${method.toUpperCase()} ${path}`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function toParamMap(params) {
|
|
145
|
+
const map = {};
|
|
146
|
+
for (const p of params) {
|
|
147
|
+
map[`${p.in}:${p.name}`] = p;
|
|
148
|
+
}
|
|
149
|
+
return map;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ─── Responses ──────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
function diffResponses(path, method, baseResponses, targetResponses, diffs) {
|
|
155
|
+
for (const statusCode of Object.keys(baseResponses)) {
|
|
156
|
+
if (!targetResponses[statusCode]) {
|
|
157
|
+
diffs.push({
|
|
158
|
+
type: 'RESPONSE_REMOVED',
|
|
159
|
+
path,
|
|
160
|
+
method,
|
|
161
|
+
field: `responses.${statusCode}`,
|
|
162
|
+
description: `Response status ${statusCode} was removed from ${method.toUpperCase()} ${path}`,
|
|
163
|
+
});
|
|
164
|
+
} else {
|
|
165
|
+
diffSchemaNode(path, method, `responses.${statusCode}`, baseResponses[statusCode], targetResponses[statusCode], diffs, false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const statusCode of Object.keys(targetResponses)) {
|
|
170
|
+
if (!baseResponses[statusCode]) {
|
|
171
|
+
diffs.push({
|
|
172
|
+
type: 'RESPONSE_ADDED',
|
|
173
|
+
path,
|
|
174
|
+
method,
|
|
175
|
+
field: `responses.${statusCode}`,
|
|
176
|
+
description: `Response status ${statusCode} was added to ${method.toUpperCase()} ${path}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Schema diffing ─────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isRequest) {
|
|
185
|
+
if (!base && !target) return;
|
|
186
|
+
if (!base || !target) return;
|
|
187
|
+
|
|
188
|
+
const baseProps = base.properties || {};
|
|
189
|
+
const targetProps = target.properties || {};
|
|
190
|
+
const baseRequired = base.required || [];
|
|
191
|
+
const targetRequired = target.required || [];
|
|
192
|
+
|
|
193
|
+
// Check type change at node level
|
|
194
|
+
if (base.type && target.type && base.type !== target.type) {
|
|
195
|
+
diffs.push({
|
|
196
|
+
type: isRequest ? 'REQUEST_TYPE_CHANGED' : 'RESPONSE_TYPE_CHANGED',
|
|
197
|
+
path,
|
|
198
|
+
method,
|
|
199
|
+
field: fieldPrefix,
|
|
200
|
+
oldValue: base.type,
|
|
201
|
+
newValue: target.type,
|
|
202
|
+
description: `Type of "${fieldPrefix}" changed from "${base.type}" to "${target.type}" in ${method.toUpperCase()} ${path}`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check enum changes
|
|
207
|
+
diffEnums(path, method, fieldPrefix, base.enum, target.enum, diffs);
|
|
208
|
+
|
|
209
|
+
// Removed properties
|
|
210
|
+
for (const field of Object.keys(baseProps)) {
|
|
211
|
+
const fullField = `${fieldPrefix}.${field}`;
|
|
212
|
+
if (!targetProps[field]) {
|
|
213
|
+
diffs.push({
|
|
214
|
+
type: isRequest ? 'REQUEST_FIELD_REMOVED' : 'RESPONSE_FIELD_REMOVED',
|
|
215
|
+
path,
|
|
216
|
+
method,
|
|
217
|
+
field: fullField,
|
|
218
|
+
description: `Field "${fullField}" was removed from ${method.toUpperCase()} ${path}`,
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
const bField = baseProps[field];
|
|
222
|
+
const tField = targetProps[field];
|
|
223
|
+
|
|
224
|
+
// Type change
|
|
225
|
+
const bType = schemaTypeStr(bField);
|
|
226
|
+
const tType = schemaTypeStr(tField);
|
|
227
|
+
if (bType !== tType) {
|
|
228
|
+
diffs.push({
|
|
229
|
+
type: isRequest ? 'REQUEST_FIELD_TYPE_CHANGED' : 'RESPONSE_FIELD_TYPE_CHANGED',
|
|
230
|
+
path,
|
|
231
|
+
method,
|
|
232
|
+
field: fullField,
|
|
233
|
+
oldValue: bType,
|
|
234
|
+
newValue: tType,
|
|
235
|
+
description: `Field "${fullField}" type changed from "${bType}" to "${tType}" in ${method.toUpperCase()} ${path}`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Required changed
|
|
240
|
+
const wasRequired = baseRequired.includes(field);
|
|
241
|
+
const isRequired = targetRequired.includes(field);
|
|
242
|
+
if (!wasRequired && isRequired) {
|
|
243
|
+
diffs.push({
|
|
244
|
+
type: 'FIELD_BECAME_REQUIRED',
|
|
245
|
+
path,
|
|
246
|
+
method,
|
|
247
|
+
field: fullField,
|
|
248
|
+
description: `Field "${fullField}" became required in ${method.toUpperCase()} ${path}`,
|
|
249
|
+
});
|
|
250
|
+
} else if (wasRequired && !isRequired) {
|
|
251
|
+
diffs.push({
|
|
252
|
+
type: 'FIELD_BECAME_OPTIONAL',
|
|
253
|
+
path,
|
|
254
|
+
method,
|
|
255
|
+
field: fullField,
|
|
256
|
+
description: `Field "${fullField}" became optional in ${method.toUpperCase()} ${path}`,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Enum changes on field
|
|
261
|
+
diffEnums(path, method, fullField, bField.enum, tField.enum, diffs);
|
|
262
|
+
|
|
263
|
+
// Recurse into nested objects
|
|
264
|
+
if (bField.properties || tField.properties) {
|
|
265
|
+
diffSchemaNode(path, method, fullField, bField, tField, diffs, isRequest);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Added properties
|
|
271
|
+
for (const field of Object.keys(targetProps)) {
|
|
272
|
+
if (!baseProps[field]) {
|
|
273
|
+
const fullField = `${fieldPrefix}.${field}`;
|
|
274
|
+
const isRequired = targetRequired.includes(field);
|
|
275
|
+
diffs.push({
|
|
276
|
+
type: isRequest
|
|
277
|
+
? isRequired ? 'REQUEST_REQUIRED_FIELD_ADDED' : 'REQUEST_FIELD_ADDED'
|
|
278
|
+
: 'RESPONSE_FIELD_ADDED',
|
|
279
|
+
path,
|
|
280
|
+
method,
|
|
281
|
+
field: fullField,
|
|
282
|
+
description: `Field "${fullField}" was added to ${method.toUpperCase()} ${path}${isRequest && isRequired ? ' (required)' : ''}`,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Diff array items
|
|
288
|
+
if (base.items || target.items) {
|
|
289
|
+
const itemsField = `${fieldPrefix}[items]`;
|
|
290
|
+
if (base.items && target.items) {
|
|
291
|
+
const baseItemType = schemaTypeStr(base.items);
|
|
292
|
+
const targetItemType = schemaTypeStr(target.items);
|
|
293
|
+
if (baseItemType !== targetItemType) {
|
|
294
|
+
diffs.push({
|
|
295
|
+
type: isRequest ? 'REQUEST_FIELD_TYPE_CHANGED' : 'RESPONSE_FIELD_TYPE_CHANGED',
|
|
296
|
+
path,
|
|
297
|
+
method,
|
|
298
|
+
field: itemsField,
|
|
299
|
+
oldValue: baseItemType,
|
|
300
|
+
newValue: targetItemType,
|
|
301
|
+
description: `Array item type of "${fieldPrefix}" changed from "${baseItemType}" to "${targetItemType}" in ${method.toUpperCase()} ${path}`,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// Recurse into items if they contain object properties
|
|
305
|
+
if (base.items.properties || target.items.properties) {
|
|
306
|
+
diffSchemaNode(path, method, itemsField, base.items, target.items, diffs, isRequest);
|
|
307
|
+
}
|
|
308
|
+
} else if (base.items && !target.items) {
|
|
309
|
+
diffs.push({
|
|
310
|
+
type: isRequest ? 'REQUEST_FIELD_REMOVED' : 'RESPONSE_FIELD_REMOVED',
|
|
311
|
+
path,
|
|
312
|
+
method,
|
|
313
|
+
field: itemsField,
|
|
314
|
+
description: `Array items schema was removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function diffEnums(path, method, fieldPrefix, baseEnum, targetEnum, diffs) {
|
|
321
|
+
if (!baseEnum || !targetEnum) return;
|
|
322
|
+
for (const val of baseEnum) {
|
|
323
|
+
if (!targetEnum.includes(val)) {
|
|
324
|
+
diffs.push({
|
|
325
|
+
type: 'ENUM_VALUE_REMOVED',
|
|
326
|
+
path,
|
|
327
|
+
method,
|
|
328
|
+
field: fieldPrefix,
|
|
329
|
+
oldValue: String(val),
|
|
330
|
+
description: `Enum value "${val}" was removed from "${fieldPrefix}" in ${method.toUpperCase()} ${path}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ─── Component schemas ───────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
function diffSchemas(baseSchemas, targetSchemas, diffs) {
|
|
339
|
+
for (const name of Object.keys(baseSchemas)) {
|
|
340
|
+
if (!targetSchemas[name]) {
|
|
341
|
+
diffs.push({
|
|
342
|
+
type: 'SCHEMA_REMOVED',
|
|
343
|
+
field: `components.schemas.${name}`,
|
|
344
|
+
description: `Schema "${name}" was removed from components.schemas`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const name of Object.keys(targetSchemas)) {
|
|
350
|
+
if (!baseSchemas[name]) {
|
|
351
|
+
diffs.push({
|
|
352
|
+
type: 'SCHEMA_ADDED',
|
|
353
|
+
field: `components.schemas.${name}`,
|
|
354
|
+
description: `Schema "${name}" was added to components.schemas`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function schemaTypeStr(schema) {
|
|
363
|
+
if (!schema) return 'unknown';
|
|
364
|
+
if (schema.ref) return schema.ref;
|
|
365
|
+
let t = schema.type || 'object';
|
|
366
|
+
if (schema.format) t += `:${schema.format}`;
|
|
367
|
+
return t;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = { diffSpecs };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve exit code based on comparison result and options.
|
|
5
|
+
* 0 = success
|
|
6
|
+
* 1 = breaking changes found and fail-on-breaking active
|
|
7
|
+
* 2 = runtime/input error (handled elsewhere)
|
|
8
|
+
*/
|
|
9
|
+
function resolveExitCode(result, options) {
|
|
10
|
+
const hasBreaking = result.breakingChanges && result.breakingChanges.length > 0;
|
|
11
|
+
|
|
12
|
+
if (hasBreaking && options.failOnBreaking && !options.allowBreaking) {
|
|
13
|
+
return 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { resolveExitCode };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fsExtra = require('fs-extra');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load a spec file from the local filesystem.
|
|
8
|
+
* Returns raw string content.
|
|
9
|
+
*/
|
|
10
|
+
async function loadSpec(filePath) {
|
|
11
|
+
const resolved = path.resolve(filePath);
|
|
12
|
+
|
|
13
|
+
const exists = await fsExtra.pathExists(resolved);
|
|
14
|
+
if (!exists) {
|
|
15
|
+
throw new Error(`Spec file not found: ${resolved}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const stat = await fsExtra.stat(resolved);
|
|
19
|
+
if (!stat.isFile()) {
|
|
20
|
+
throw new Error(`Path is not a file: ${resolved}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const content = await fsExtra.readFile(resolved, 'utf8');
|
|
24
|
+
if (!content || content.trim().length === 0) {
|
|
25
|
+
throw new Error(`Spec file is empty: ${resolved}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { loadSpec };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize an OpenAPI spec into a flat, consistent internal model.
|
|
5
|
+
* Resolves simple $ref references from components.schemas.
|
|
6
|
+
*/
|
|
7
|
+
function normalizeSpec(spec) {
|
|
8
|
+
if (!spec || typeof spec !== 'object') {
|
|
9
|
+
throw new Error('Invalid spec: must be an object');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const components = spec.components || {};
|
|
13
|
+
const schemas = components.schemas || {};
|
|
14
|
+
|
|
15
|
+
const endpoints = {};
|
|
16
|
+
|
|
17
|
+
const paths = spec.paths || {};
|
|
18
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
19
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
20
|
+
|
|
21
|
+
const methods = {};
|
|
22
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
23
|
+
|
|
24
|
+
for (const method of HTTP_METHODS) {
|
|
25
|
+
if (!pathItem[method]) continue;
|
|
26
|
+
const operation = pathItem[method];
|
|
27
|
+
methods[method] = normalizeOperation(operation, schemas);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (Object.keys(methods).length > 0) {
|
|
31
|
+
endpoints[pathKey] = methods;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
info: spec.info || {},
|
|
37
|
+
endpoints,
|
|
38
|
+
schemas: normalizeSchemaMap(schemas, schemas),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeOperation(operation, schemas) {
|
|
43
|
+
return {
|
|
44
|
+
operationId: operation.operationId || null,
|
|
45
|
+
summary: operation.summary || null,
|
|
46
|
+
parameters: normalizeParameters(operation.parameters || [], schemas),
|
|
47
|
+
requestBody: normalizeRequestBody(operation.requestBody, schemas),
|
|
48
|
+
responses: normalizeResponses(operation.responses || {}, schemas),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeParameters(params, schemas) {
|
|
53
|
+
return params.map((p) => ({
|
|
54
|
+
name: p.name,
|
|
55
|
+
in: p.in,
|
|
56
|
+
required: Boolean(p.required),
|
|
57
|
+
schema: p.schema ? resolveSchema(p.schema, schemas) : null,
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeRequestBody(requestBody, schemas) {
|
|
62
|
+
if (!requestBody) return null;
|
|
63
|
+
const content = requestBody.content || {};
|
|
64
|
+
const mediaType = content['application/json'] || Object.values(content)[0];
|
|
65
|
+
if (!mediaType || !mediaType.schema) return null;
|
|
66
|
+
return resolveSchema(mediaType.schema, schemas);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeResponses(responses, schemas) {
|
|
70
|
+
const result = {};
|
|
71
|
+
for (const [statusCode, response] of Object.entries(responses)) {
|
|
72
|
+
if (!response) continue;
|
|
73
|
+
const content = response.content || {};
|
|
74
|
+
const mediaType = content['application/json'] || Object.values(content)[0];
|
|
75
|
+
result[statusCode] = mediaType && mediaType.schema
|
|
76
|
+
? resolveSchema(mediaType.schema, schemas)
|
|
77
|
+
: null;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeSchemaMap(schemaMap, allSchemas) {
|
|
83
|
+
const result = {};
|
|
84
|
+
for (const [name, schema] of Object.entries(schemaMap)) {
|
|
85
|
+
result[name] = resolveSchema(schema, allSchemas);
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve a schema node, following simple $ref to components.schemas.
|
|
92
|
+
* Returns a normalized schema node with { type, properties, required, items, enum }.
|
|
93
|
+
*/
|
|
94
|
+
function resolveSchema(schema, schemas, depth = 0) {
|
|
95
|
+
if (!schema || typeof schema !== 'object') return { type: 'unknown' };
|
|
96
|
+
if (depth > 10) return { type: 'circular' };
|
|
97
|
+
|
|
98
|
+
if (schema.$ref) {
|
|
99
|
+
const refName = schema.$ref.replace('#/components/schemas/', '');
|
|
100
|
+
const resolved = schemas[refName];
|
|
101
|
+
if (!resolved) return { type: 'unknown', ref: schema.$ref };
|
|
102
|
+
return resolveSchema(resolved, schemas, depth + 1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Handle allOf / oneOf / anyOf by merging
|
|
106
|
+
if (schema.allOf) {
|
|
107
|
+
return mergeSchemas(schema.allOf, schemas, depth);
|
|
108
|
+
}
|
|
109
|
+
if (schema.oneOf || schema.anyOf) {
|
|
110
|
+
const list = schema.oneOf || schema.anyOf;
|
|
111
|
+
return mergeSchemas(list, schemas, depth);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const node = {
|
|
115
|
+
type: schema.type || 'object',
|
|
116
|
+
format: schema.format || null,
|
|
117
|
+
enum: schema.enum || null,
|
|
118
|
+
nullable: Boolean(schema.nullable),
|
|
119
|
+
required: Array.isArray(schema.required) ? schema.required : [],
|
|
120
|
+
properties: {},
|
|
121
|
+
items: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
if (schema.properties) {
|
|
125
|
+
for (const [key, val] of Object.entries(schema.properties)) {
|
|
126
|
+
node.properties[key] = resolveSchema(val, schemas, depth + 1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (schema.items) {
|
|
131
|
+
node.items = resolveSchema(schema.items, schemas, depth + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return node;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mergeSchemas(schemaList, schemas, depth) {
|
|
138
|
+
const merged = { type: 'object', required: [], properties: {}, items: null, enum: null, nullable: false };
|
|
139
|
+
for (const s of schemaList) {
|
|
140
|
+
const resolved = resolveSchema(s, schemas, depth + 1);
|
|
141
|
+
if (resolved.properties) Object.assign(merged.properties, resolved.properties);
|
|
142
|
+
if (resolved.required) merged.required.push(...resolved.required);
|
|
143
|
+
if (resolved.type && resolved.type !== 'object') merged.type = resolved.type;
|
|
144
|
+
if (resolved.enum) merged.enum = resolved.enum;
|
|
145
|
+
}
|
|
146
|
+
return merged;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { normalizeSpec, resolveSchema };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
function formatHuman(result) {
|
|
6
|
+
const lines = [];
|
|
7
|
+
const { breakingChanges, additions, modifications, warnings } = result;
|
|
8
|
+
|
|
9
|
+
const total = breakingChanges.length + additions.length + modifications.length + warnings.length;
|
|
10
|
+
|
|
11
|
+
if (total === 0) {
|
|
12
|
+
lines.push(chalk.green('✔ No changes detected between the two specs.'));
|
|
13
|
+
return lines.join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Summary header
|
|
17
|
+
lines.push('');
|
|
18
|
+
lines.push(chalk.bold(' SpecShield Comparison Report'));
|
|
19
|
+
lines.push(chalk.gray(' ─────────────────────────────────────────'));
|
|
20
|
+
lines.push(
|
|
21
|
+
` ${chalk.red.bold(`${breakingChanges.length} breaking`)} ` +
|
|
22
|
+
`${chalk.green(`${additions.length} additions`)} ` +
|
|
23
|
+
`${chalk.yellow(`${modifications.length} modifications`)} ` +
|
|
24
|
+
`${chalk.gray(`${warnings.length} warnings`)}`
|
|
25
|
+
);
|
|
26
|
+
lines.push('');
|
|
27
|
+
|
|
28
|
+
if (breakingChanges.length > 0) {
|
|
29
|
+
lines.push(chalk.red.bold(' ✖ BREAKING CHANGES'));
|
|
30
|
+
lines.push(chalk.gray(' ─────────────────────────────────────────'));
|
|
31
|
+
for (const c of breakingChanges) {
|
|
32
|
+
lines.push(` ${chalk.red('●')} ${c.description}`);
|
|
33
|
+
if (c.oldValue && c.newValue) {
|
|
34
|
+
lines.push(` ${chalk.gray('from:')} ${chalk.red(c.oldValue)} ${chalk.gray('→')} ${chalk.green(c.newValue)}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (additions.length > 0) {
|
|
41
|
+
lines.push(chalk.green.bold(' ✚ ADDITIONS'));
|
|
42
|
+
lines.push(chalk.gray(' ─────────────────────────────────────────'));
|
|
43
|
+
for (const c of additions) {
|
|
44
|
+
lines.push(` ${chalk.green('+')} ${c.description}`);
|
|
45
|
+
}
|
|
46
|
+
lines.push('');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (modifications.length > 0) {
|
|
50
|
+
lines.push(chalk.yellow.bold(' ✎ MODIFICATIONS'));
|
|
51
|
+
lines.push(chalk.gray(' ─────────────────────────────────────────'));
|
|
52
|
+
for (const c of modifications) {
|
|
53
|
+
lines.push(` ${chalk.yellow('~')} ${c.description}`);
|
|
54
|
+
if (c.oldValue && c.newValue) {
|
|
55
|
+
lines.push(` ${chalk.gray('from:')} ${chalk.yellow(c.oldValue)} ${chalk.gray('→')} ${chalk.yellow(c.newValue)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
lines.push('');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (warnings.length > 0) {
|
|
62
|
+
lines.push(chalk.gray.bold(' ⚠ WARNINGS'));
|
|
63
|
+
lines.push(chalk.gray(' ─────────────────────────────────────────'));
|
|
64
|
+
for (const c of warnings) {
|
|
65
|
+
lines.push(` ${chalk.gray('!')} ${c.description}`);
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatJson(result) {
|
|
74
|
+
return {
|
|
75
|
+
summary: {
|
|
76
|
+
breaking: result.breakingChanges.length,
|
|
77
|
+
additions: result.additions.length,
|
|
78
|
+
modifications: result.modifications.length,
|
|
79
|
+
warnings: result.warnings.length,
|
|
80
|
+
},
|
|
81
|
+
breakingChanges: result.breakingChanges,
|
|
82
|
+
additions: result.additions,
|
|
83
|
+
modifications: result.modifications,
|
|
84
|
+
warnings: result.warnings,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { formatHuman, formatJson };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse raw spec content (YAML or JSON) into a JavaScript object.
|
|
8
|
+
* Detects format from file extension or content.
|
|
9
|
+
*/
|
|
10
|
+
function parseSpec(content, filePath) {
|
|
11
|
+
const ext = filePath ? path.extname(filePath).toLowerCase() : '';
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (ext === '.json') {
|
|
15
|
+
return parseJson(content);
|
|
16
|
+
} else if (ext === '.yaml' || ext === '.yml') {
|
|
17
|
+
return parseYaml(content);
|
|
18
|
+
} else {
|
|
19
|
+
// Auto-detect: try JSON first, then YAML
|
|
20
|
+
return autoDetect(content);
|
|
21
|
+
}
|
|
22
|
+
} catch (err) {
|
|
23
|
+
throw new Error(`Failed to parse spec "${filePath}": ${err.message}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseJson(content) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new Error(`Invalid JSON: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseYaml(content) {
|
|
36
|
+
const result = yaml.load(content);
|
|
37
|
+
if (result === null || typeof result !== 'object') {
|
|
38
|
+
throw new Error('YAML did not produce a valid object');
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function autoDetect(content) {
|
|
44
|
+
const trimmed = content.trim();
|
|
45
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
46
|
+
return parseJson(content);
|
|
47
|
+
}
|
|
48
|
+
return parseYaml(content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { parseSpec, parseJson, parseYaml };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const logger = {
|
|
6
|
+
info(msg) {
|
|
7
|
+
process.stderr.write(chalk.blue('ℹ ') + msg + '\n');
|
|
8
|
+
},
|
|
9
|
+
warn(msg) {
|
|
10
|
+
process.stderr.write(chalk.yellow('⚠ ') + msg + '\n');
|
|
11
|
+
},
|
|
12
|
+
error(msg) {
|
|
13
|
+
process.stderr.write(chalk.red('✖ ') + msg + '\n');
|
|
14
|
+
},
|
|
15
|
+
debug(msg) {
|
|
16
|
+
if (process.env.SPECSHIELD_DEBUG) {
|
|
17
|
+
process.stderr.write(chalk.gray('[debug] ') + msg + '\n');
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = logger;
|