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 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
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ require('../src/cli');
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;