specshield 3.1.2 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +88 -1
- package/package.json +5 -2
- package/scripts/welcome.js +70 -0
- package/src/cli.js +4 -0
- package/src/commands/compare.js +23 -1
- package/src/commands/history.js +110 -0
- package/src/commands/init.js +55 -3
- package/src/commands/share.js +121 -0
- package/src/core/classifyChanges.js +118 -0
- package/src/core/conversionPrompt.js +138 -0
- package/src/core/diffEngine.js +99 -0
- package/src/core/normalizeSpec.js +12 -0
- package/src/core/parseSpec.js +30 -4
- package/src/core/projectDetect.js +8 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# SpecShield CLI changelog
|
|
2
|
+
|
|
3
|
+
## 3.2.0 — 2026-05-17 — Conversion fixes
|
|
4
|
+
|
|
5
|
+
Three CLI changes designed to make the Cloud features (history, share URLs,
|
|
6
|
+
PR checks, BDCT) visible to the 2,000+ existing CLI users who run local
|
|
7
|
+
`specshield compare` but never discover what signing up unlocks.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Post-install welcome banner** — runs once after a fresh `npm install -g specshield`.
|
|
12
|
+
Briefly explains the value progression (Local → Cloud Free → Pro) and points
|
|
13
|
+
to the next command to try. Skipped automatically in CI, in non-TTY shells,
|
|
14
|
+
on update installs, and when `SPECSHIELD_NO_BANNER=1` is set.
|
|
15
|
+
|
|
16
|
+
- **Contextual signup prompt after `specshield compare`** — after 3+ compares
|
|
17
|
+
per week, a soft 3-line nudge appears below the regular output:
|
|
18
|
+
```
|
|
19
|
+
● Track these comparisons over time:
|
|
20
|
+
specshield login # 30-sec signup via GitHub / Google · no credit card
|
|
21
|
+
Unlocks: compare history, shareable report URLs, PR badge
|
|
22
|
+
```
|
|
23
|
+
Throttled to once per week so it never spams. Suppressed entirely for
|
|
24
|
+
logged-in users, `--json` output, CI environments, and on opt-out. The
|
|
25
|
+
prompt copy escalates at 10 and 25 compares per window.
|
|
26
|
+
|
|
27
|
+
- **`specshield history`** — new command to list recent comparisons saved
|
|
28
|
+
in your Cloud account. Surfaces in `specshield --help` so local-only
|
|
29
|
+
users discover the feature exists.
|
|
30
|
+
|
|
31
|
+
- **`specshield share <report-id | base.yaml target.yaml>`** — generate a
|
|
32
|
+
public shareable URL for a comparison. Designed for pasting diffs into
|
|
33
|
+
Slack, PR comments, or Jira. Cloud account required.
|
|
34
|
+
|
|
35
|
+
Both new commands print a friendly "Get started in 30 seconds" message
|
|
36
|
+
with a `specshield login` deep link when run without credentials.
|
|
37
|
+
|
|
38
|
+
### Why
|
|
39
|
+
|
|
40
|
+
Background: in May 2026 the CLI had 2,000+ active monthly users (npm download
|
|
41
|
+
estimate; real human count likely 200-500) but the SpecShield Cloud signup
|
|
42
|
+
rate from the CLI was effectively zero. Local compare was so capable that
|
|
43
|
+
users got 100% of their immediate value without ever needing an account.
|
|
44
|
+
|
|
45
|
+
These changes don't remove any free functionality — local compare is still
|
|
46
|
+
fully usable without signup — they just make the gap between local and
|
|
47
|
+
cloud visible at the moments when a user is most engaged (post-install,
|
|
48
|
+
post-compare, on `--help`).
|
|
49
|
+
|
|
50
|
+
### Tests
|
|
51
|
+
|
|
52
|
+
- Added `tests/core/conversionPrompt.test.js` covering all skip conditions,
|
|
53
|
+
threshold logic, escalation, and 7-day window reset (10 tests).
|
|
54
|
+
- All 134 existing tests still pass.
|
|
55
|
+
|
|
56
|
+
### Opting out
|
|
57
|
+
|
|
58
|
+
If you don't want the banner or contextual prompts:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
export SPECSHIELD_NO_BANNER=1 # disable banner + post-compare nudge
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or just sign up — logged-in users never see either.
|
package/README.md
CHANGED
|
@@ -137,6 +137,15 @@ specshield bdct can-i-deploy --version $GITHUB_SHA
|
|
|
137
137
|
|
|
138
138
|
See [§ specshield init](#specshield-init--first-run-setup-wizard) below.
|
|
139
139
|
|
|
140
|
+
> **Quiet install for CI / Docker images:**
|
|
141
|
+
> The post-install welcome banner auto-detects CI environments (`CI`,
|
|
142
|
+
> `GITHUB_ACTIONS`, `BUILDKITE`, `CIRCLECI`, `GITLAB_CI`, `JENKINS_URL`,
|
|
143
|
+
> `TRAVIS`, `TF_BUILD`) and skips itself there — so your CI logs stay clean.
|
|
144
|
+
> To silence it on a workstation too:
|
|
145
|
+
> ```bash
|
|
146
|
+
> export SPECSHIELD_NO_BANNER=1
|
|
147
|
+
> ```
|
|
148
|
+
|
|
140
149
|
---
|
|
141
150
|
|
|
142
151
|
## 🚀 Create Your Free Account
|
|
@@ -160,7 +169,9 @@ See [§ specshield init](#specshield-init--first-run-setup-wizard) below.
|
|
|
160
169
|
| Breaking change detection | ✅ | ✅ | ✅ |
|
|
161
170
|
| JSON / human output | ✅ | ✅ | ✅ |
|
|
162
171
|
| Fail CI on breaking change | ✅ | ✅ | ✅ |
|
|
163
|
-
|
|
|
172
|
+
| **`specshield history` — compare timeline** | ❌ | ✅ | ✅ |
|
|
173
|
+
| **`specshield share` — public report URLs** | ❌ | ✅ | ✅ |
|
|
174
|
+
| **Dashboard** | ❌ | ✅ | ✅ |
|
|
164
175
|
| **GitHub App PR checks** | ❌ | ✅ | ✅ |
|
|
165
176
|
| **BDCT bi-directional contracts** | ❌ | ❌ | ✅ |
|
|
166
177
|
| **BDCT can-i-deploy gating** | ❌ | ❌ | ✅ |
|
|
@@ -322,6 +333,63 @@ specshield compare base.yaml target.yaml --remote --json --output result.json
|
|
|
322
333
|
|
|
323
334
|
---
|
|
324
335
|
|
|
336
|
+
## Comparison History
|
|
337
|
+
|
|
338
|
+
Every `specshield compare --remote` is saved to your SpecShield account.
|
|
339
|
+
List the recent comparisons your account has run from any machine — useful
|
|
340
|
+
for tracking API drift over time across CI pipelines + local runs.
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
specshield history # last 20 comparisons
|
|
344
|
+
specshield history --limit 50 # show more
|
|
345
|
+
specshield history --json # machine-readable for scripts
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
Your recent comparisons
|
|
350
|
+
─────────────────────────────────────────────────────
|
|
351
|
+
482 3 breaking 2026-05-17 14:30 payment-v1.yaml → payment-v2.yaml
|
|
352
|
+
481 0 breaking 2026-05-17 11:02 user-api.yaml → user-api-updated.yaml
|
|
353
|
+
480 7 breaking 2026-05-16 18:55 billing-v3.yaml → billing-v4.yaml
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Account required — run `specshield login` to set up (free, no credit card).
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Share a Comparison
|
|
361
|
+
|
|
362
|
+
Generate a public, tokenized URL for any comparison report. Anyone with
|
|
363
|
+
the link can view the diff — no SpecShield account needed. Great for
|
|
364
|
+
pasting into Slack threads, PR comments, or Jira tickets.
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
# Share an existing report by ID (from `specshield history`)
|
|
368
|
+
specshield share 482
|
|
369
|
+
|
|
370
|
+
# Compare two specs and share the result in one step
|
|
371
|
+
specshield share base.yaml target.yaml
|
|
372
|
+
|
|
373
|
+
# Time-limited link — expires in 30 days
|
|
374
|
+
specshield share 482 --expires 30
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
✔ Share link ready
|
|
379
|
+
─────────────────────────────────────────────────────
|
|
380
|
+
https://specshield.io/r/_Ru8OVubxY3r9zHOsylESaULphCqBYH5jTPYldSMU88
|
|
381
|
+
Expires: 2026-06-16T12:34:56Z
|
|
382
|
+
|
|
383
|
+
Anyone with this link can view the diff — no SpecShield account required.
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Links use a 256-bit random token, so they can't be guessed by enumeration.
|
|
387
|
+
Revoke any time from your dashboard at [specshield.io](https://specshield.io).
|
|
388
|
+
|
|
389
|
+
Account required — `specshield login` to set up.
|
|
390
|
+
|
|
391
|
+
---
|
|
392
|
+
|
|
325
393
|
## GitHub Integration
|
|
326
394
|
|
|
327
395
|
**Automatic API contract checks on every pull request — no workflow YAML required.**
|
|
@@ -989,6 +1057,25 @@ specshield compare <base> <target> [options]
|
|
|
989
1057
|
| `--config <path>` | Path to `.specshield.yml` |
|
|
990
1058
|
| `--timeout <ms>` | Request timeout for remote mode |
|
|
991
1059
|
|
|
1060
|
+
```bash
|
|
1061
|
+
specshield history [options]
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
| Option | Description |
|
|
1065
|
+
|---|---|
|
|
1066
|
+
| `--limit <n>` | Number of comparisons to list (default 20) |
|
|
1067
|
+
| `--json` | Machine-readable JSON output |
|
|
1068
|
+
| `--api-key <key>` | Override stored API key |
|
|
1069
|
+
|
|
1070
|
+
```bash
|
|
1071
|
+
specshield share <reportId | base.yaml target.yaml> [options]
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
| Option | Description |
|
|
1075
|
+
|---|---|
|
|
1076
|
+
| `--expires <days>` | Make the link expire after N days (default: never) |
|
|
1077
|
+
| `--api-key <key>` | Override stored API key |
|
|
1078
|
+
|
|
992
1079
|
```bash
|
|
993
1080
|
specshield bdct <subcommand> [options]
|
|
994
1081
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specshield",
|
|
3
|
-
"version": "3.1
|
|
3
|
+
"version": "3.2.1",
|
|
4
4
|
"description": "CLI for OpenAPI breaking change detection and bi-directional contract verification — with can-i-deploy gating, GitHub PR checks, and a first-run setup wizard.",
|
|
5
5
|
"main": "src/cli.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"start": "node bin/specshield.js",
|
|
11
11
|
"test": "jest --coverage",
|
|
12
12
|
"test:watch": "jest --watch",
|
|
13
|
-
"lint": "eslint src tests --ext .js"
|
|
13
|
+
"lint": "eslint src tests --ext .js",
|
|
14
|
+
"postinstall": "node scripts/welcome.js || true"
|
|
14
15
|
},
|
|
15
16
|
"keywords": [
|
|
16
17
|
"openapi",
|
|
@@ -47,7 +48,9 @@
|
|
|
47
48
|
"files": [
|
|
48
49
|
"bin",
|
|
49
50
|
"src",
|
|
51
|
+
"scripts",
|
|
50
52
|
"README.md",
|
|
53
|
+
"CHANGELOG.md",
|
|
51
54
|
"LICENSE"
|
|
52
55
|
],
|
|
53
56
|
"dependencies": {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Post-install welcome banner.
|
|
4
|
+
*
|
|
5
|
+
* Runs once via the `postinstall` hook in package.json after a fresh
|
|
6
|
+
* `npm install -g specshield`. Prints a brief value-progression banner so
|
|
7
|
+
* users discover the cloud features that exist beyond local compare —
|
|
8
|
+
* historically users would install, run compare, get value, and never look
|
|
9
|
+
* at the README to find out signup unlocks more.
|
|
10
|
+
*
|
|
11
|
+
* We bail out silently in environments where a banner would be noise or
|
|
12
|
+
* could break automation:
|
|
13
|
+
* - CI environment variables present (CI, GITHUB_ACTIONS, BUILDKITE,
|
|
14
|
+
* CIRCLECI, GITLAB_CI, JENKINS_URL, TRAVIS, TF_BUILD)
|
|
15
|
+
* - npm_config_loglevel is `silent` or `error` (user opted out of noise)
|
|
16
|
+
* - SPECSHIELD_NO_BANNER=1 (explicit opt-out)
|
|
17
|
+
* - Not running under npm (`npm_command` unset)
|
|
18
|
+
* - Update install rather than fresh install (npm_command !== "install")
|
|
19
|
+
*
|
|
20
|
+
* No state is written; we let the user's terminal scroll and move on.
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
function shouldSkip() {
|
|
25
|
+
const env = process.env;
|
|
26
|
+
if (env.SPECSHIELD_NO_BANNER === '1') return true;
|
|
27
|
+
if (env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.CIRCLECI ||
|
|
28
|
+
env.GITLAB_CI || env.JENKINS_URL || env.TRAVIS || env.TF_BUILD) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
const loglevel = env.npm_config_loglevel;
|
|
32
|
+
if (loglevel === 'silent' || loglevel === 'error') return true;
|
|
33
|
+
// Only show on the user-initiated "install" command. Things like
|
|
34
|
+
// `npm ci`, `npm update`, transitive dep installs all set npm_command
|
|
35
|
+
// to something other than 'install'.
|
|
36
|
+
if (env.npm_command !== 'install') return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (shouldSkip()) {
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ANSI color helpers — kept local to avoid pulling in chalk during a
|
|
45
|
+
// post-install script (chalk requires node_modules to be fully populated,
|
|
46
|
+
// which is racy during installs).
|
|
47
|
+
const isTTY = !!process.stdout.isTTY;
|
|
48
|
+
const c = (code, s) => (isTTY ? `[${code}m${s}[0m` : s);
|
|
49
|
+
const bold = (s) => c('1', s);
|
|
50
|
+
const dim = (s) => c('2', s);
|
|
51
|
+
const cyan = (s) => c('36', s);
|
|
52
|
+
const grn = (s) => c('32', s);
|
|
53
|
+
|
|
54
|
+
const banner = `
|
|
55
|
+
${bold('SpecShield installed')} ${dim('— OpenAPI breaking-change detection + BDCT')}
|
|
56
|
+
|
|
57
|
+
${bold('Get started')}
|
|
58
|
+
${cyan('specshield compare')} base.yaml target.yaml --fail-on-breaking
|
|
59
|
+
${cyan('specshield init')} ${dim('# project setup wizard')}
|
|
60
|
+
|
|
61
|
+
${bold('Your usage tier')}
|
|
62
|
+
${grn('●')} ${bold('Local (free, no account)')} spec compare, breaking-change detection
|
|
63
|
+
${dim('○')} Cloud Free ${dim('+ compare history, PR badge, share URLs')}
|
|
64
|
+
${dim('○')} Pro ${dim('+ BDCT, can-i-deploy, GitHub PR checks, team')}
|
|
65
|
+
|
|
66
|
+
${dim('Sign in when you want history & sharing:')} ${cyan('specshield login')}
|
|
67
|
+
${dim('Docs:')} ${cyan('https://specshield.io/docs')}
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
process.stdout.write(banner + '\n');
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,8 @@ const initCommand = require('./commands/init');
|
|
|
7
7
|
const loginCommand = require('./commands/login');
|
|
8
8
|
const logoutCommand = require('./commands/logout');
|
|
9
9
|
const bdctCommand = require('./commands/bdct');
|
|
10
|
+
const historyCommand = require('./commands/history');
|
|
11
|
+
const shareCommand = require('./commands/share');
|
|
10
12
|
|
|
11
13
|
const program = new Command();
|
|
12
14
|
|
|
@@ -24,6 +26,8 @@ program.addCommand(initCommand);
|
|
|
24
26
|
program.addCommand(loginCommand);
|
|
25
27
|
program.addCommand(logoutCommand);
|
|
26
28
|
program.addCommand(bdctCommand);
|
|
29
|
+
program.addCommand(historyCommand);
|
|
30
|
+
program.addCommand(shareCommand);
|
|
27
31
|
|
|
28
32
|
program.parseAsync(process.argv).catch((err) => {
|
|
29
33
|
const logger = require('./utils/logger');
|
package/src/commands/compare.js
CHANGED
|
@@ -12,6 +12,7 @@ const { classifyChanges, filterBySeverity } = require('../core/classifyChanges')
|
|
|
12
12
|
const { formatHuman, formatJson } = require('../core/outputFormatter');
|
|
13
13
|
const { loadConfig } = require('../core/configLoader');
|
|
14
14
|
const { resolveExitCode } = require('../core/exitCode');
|
|
15
|
+
const { recordCompareAndMaybeRender } = require('../core/conversionPrompt');
|
|
15
16
|
const logger = require('../utils/logger');
|
|
16
17
|
const fsExtra = require('fs-extra');
|
|
17
18
|
const { getStoredApiKey } = require('../config/localConfig');
|
|
@@ -30,7 +31,12 @@ compare
|
|
|
30
31
|
.option('--allow-breaking', 'Override fail-on-breaking behavior')
|
|
31
32
|
.option('--config <path>', 'Path to .specshield.yml config file')
|
|
32
33
|
.option('--ignore <change>', 'Ignore a specific change string (repeatable)', collect, [])
|
|
33
|
-
|
|
34
|
+
// Default severity is 'info' so additions and modifications are visible
|
|
35
|
+
// by default. Customers expect a diff summary to list ALL changes, with
|
|
36
|
+
// severity coloring/filtering as an opt-in narrowing. The previous default
|
|
37
|
+
// of 'error' silently hid every non-breaking change, which read as "0
|
|
38
|
+
// additions / 0 modifications" even when both existed.
|
|
39
|
+
.option('--severity <level>', 'Minimum severity level: info | warning | error', 'info')
|
|
34
40
|
.option('--remote', 'Use the SpecShield hosted compare API')
|
|
35
41
|
.option('--api-key <key>', 'API key for hosted mode (overrides env and stored config)')
|
|
36
42
|
.option('--remote-url <url>', 'Override the hosted API base URL')
|
|
@@ -92,6 +98,22 @@ compare
|
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
// Contextual signup nudge — runs only when the user isn't logged in,
|
|
102
|
+
// output is human-readable, not in CI, and they've crossed a usage
|
|
103
|
+
// threshold this week. The recordCompareAndMaybeRender function is
|
|
104
|
+
// best-effort; any failure (disk read/write, etc.) is silently
|
|
105
|
+
// swallowed so the conversion path never blocks compare.
|
|
106
|
+
try {
|
|
107
|
+
const loggedIn = !!(options.resolvedApiKey ||
|
|
108
|
+
process.env.SPECSHIELD_API_KEY ||
|
|
109
|
+
await getStoredApiKey());
|
|
110
|
+
const prompt = await recordCompareAndMaybeRender({
|
|
111
|
+
loggedIn,
|
|
112
|
+
jsonOutput: !!options.json,
|
|
113
|
+
});
|
|
114
|
+
if (prompt) process.stdout.write('\n' + prompt + '\n');
|
|
115
|
+
} catch { /* never block on the nudge */ }
|
|
116
|
+
|
|
95
117
|
// Exit code
|
|
96
118
|
const code = resolveExitCode(result, options);
|
|
97
119
|
process.exit(code);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `specshield history` — list recent comparisons saved in the user's
|
|
5
|
+
* SpecShield Cloud account.
|
|
6
|
+
*
|
|
7
|
+
* This command's primary value isn't itself — it's that it surfaces a
|
|
8
|
+
* concrete Cloud-only feature in `specshield --help`, giving local-only
|
|
9
|
+
* users a visible "what am I missing?" signal. When run without a stored
|
|
10
|
+
* API key, it prints a brief sign-up nudge rather than failing silently.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { Command } = require('commander');
|
|
14
|
+
const chalk = require('chalk');
|
|
15
|
+
const ora = require('ora');
|
|
16
|
+
const axios = require('axios');
|
|
17
|
+
const logger = require('../utils/logger');
|
|
18
|
+
const { getStoredApiKey } = require('../config/localConfig');
|
|
19
|
+
|
|
20
|
+
const HOSTED_API_URL = 'https://specshield.io';
|
|
21
|
+
|
|
22
|
+
const history = new Command('history');
|
|
23
|
+
|
|
24
|
+
history
|
|
25
|
+
.description('Show your recent comparisons (Cloud account required)')
|
|
26
|
+
.option('--limit <n>', 'How many comparisons to show', '20')
|
|
27
|
+
.option('--json', 'Machine-readable JSON output')
|
|
28
|
+
.option('--api-key <key>', 'API key (overrides env / stored config)')
|
|
29
|
+
.option('--api-url <url>', 'Override hosted API base URL', HOSTED_API_URL)
|
|
30
|
+
.action(async (opts) => {
|
|
31
|
+
const apiKey = opts.apiKey
|
|
32
|
+
|| process.env.SPECSHIELD_API_KEY
|
|
33
|
+
|| (await getStoredApiKey())
|
|
34
|
+
|| null;
|
|
35
|
+
|
|
36
|
+
if (!apiKey) {
|
|
37
|
+
printSignupNudge();
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const spinner = opts.json ? null : ora('Fetching your comparison history...').start();
|
|
42
|
+
try {
|
|
43
|
+
const size = parseInt(opts.limit, 10) || 20;
|
|
44
|
+
const response = await axios.get(`${opts.apiUrl}/me/compare-history`, {
|
|
45
|
+
// Backend uses Spring Data Page conventions: page (0-indexed) + size.
|
|
46
|
+
params: { page: 0, size },
|
|
47
|
+
headers: { 'X-Api-Key': apiKey, 'X-SpecShield-Client': 'cli' },
|
|
48
|
+
timeout: 10000,
|
|
49
|
+
});
|
|
50
|
+
if (spinner) spinner.stop();
|
|
51
|
+
|
|
52
|
+
const items = response.data?.content
|
|
53
|
+
|| response.data?.items
|
|
54
|
+
|| (Array.isArray(response.data) ? response.data : []);
|
|
55
|
+
|
|
56
|
+
if (opts.json) {
|
|
57
|
+
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (items.length === 0) {
|
|
62
|
+
console.log(chalk.gray('\n No comparisons in your history yet. Run:'));
|
|
63
|
+
console.log(chalk.cyan(' specshield compare a.yaml b.yaml --remote\n'));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log('\n ' + chalk.bold('Your recent comparisons'));
|
|
68
|
+
console.log(chalk.gray(' ─────────────────────────────────────────────────────'));
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const id = item.id || item.reportId || '—';
|
|
71
|
+
const created = item.createdAt || item.timestamp || '—';
|
|
72
|
+
const breaks = item.breakingCount ?? item.breakingChanges?.length ?? '—';
|
|
73
|
+
// CompareHistorySummaryDto exposes base + target spec names — combine
|
|
74
|
+
// for a "a.yaml → b.yaml" summary that matches what the user typed.
|
|
75
|
+
const summary = item.baseSpecName && item.targetSpecName
|
|
76
|
+
? `${item.baseSpecName} → ${item.targetSpecName}`
|
|
77
|
+
: (item.summary || item.baseSpecName || '');
|
|
78
|
+
const breakBadge = breaks === 0 || breaks === '—'
|
|
79
|
+
? chalk.green(`${breaks} breaking`)
|
|
80
|
+
: chalk.red(`${breaks} breaking`);
|
|
81
|
+
console.log(` ${chalk.cyan(String(id).padEnd(10))} ${breakBadge.padEnd(18)} ${chalk.gray(String(created))} ${summary}`);
|
|
82
|
+
}
|
|
83
|
+
console.log();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (spinner) spinner.fail('Could not fetch history');
|
|
86
|
+
const msg = err.response
|
|
87
|
+
? `${err.response.status}: ${JSON.stringify(err.response.data)}`
|
|
88
|
+
: err.message;
|
|
89
|
+
logger.error(`Failed to fetch history: ${msg}`);
|
|
90
|
+
process.exit(2);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function printSignupNudge() {
|
|
95
|
+
console.log('');
|
|
96
|
+
console.log(chalk.bold(' specshield history') + chalk.gray(' — Cloud feature'));
|
|
97
|
+
console.log('');
|
|
98
|
+
console.log(' Comparison history is part of your SpecShield Cloud account.');
|
|
99
|
+
console.log(' Every ' + chalk.cyan('specshield compare --remote') + ' you run is saved with the diff,');
|
|
100
|
+
console.log(' date, and a shareable report URL.');
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(chalk.bold(' Get started in 30 seconds:'));
|
|
103
|
+
console.log(' ' + chalk.cyan('specshield login') + chalk.gray(' # API key from https://specshield.io'));
|
|
104
|
+
console.log(' ' + chalk.gray('or visit https://specshield.io and sign in with GitHub / Google'));
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(' ' + chalk.gray('Cloud is free for personal use.'));
|
|
107
|
+
console.log('');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = history;
|
package/src/commands/init.js
CHANGED
|
@@ -326,6 +326,43 @@ function nonInteractiveFlow(detected, opts) {
|
|
|
326
326
|
return answers;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
// ─── Preview flow ──────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Used by `specshield init --print`. Behaves like `nonInteractiveFlow` but
|
|
333
|
+
* substitutes a "<replace-me>" placeholder for any required field the user
|
|
334
|
+
* didn't supply, instead of erroring out. The header comment printed
|
|
335
|
+
* alongside the YAML tells the user where to fill these in.
|
|
336
|
+
*
|
|
337
|
+
* Kind defaults to "provider" if a spec was detected (the most common
|
|
338
|
+
* case), else "skip" (local-compare-only).
|
|
339
|
+
*/
|
|
340
|
+
function previewFlow(detected, opts) {
|
|
341
|
+
const PLACEHOLDER = '<replace-me>';
|
|
342
|
+
const kind = opts.kind || (detected.spec ? 'provider' : 'skip');
|
|
343
|
+
|
|
344
|
+
const answers = {
|
|
345
|
+
kind,
|
|
346
|
+
server: opts.server || DEFAULT_SERVER,
|
|
347
|
+
org: opts.org || (kind === 'skip' ? null : PLACEHOLDER),
|
|
348
|
+
environment: opts.env || detected.environment || 'staging',
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
if (kind === 'provider' || kind === 'both') {
|
|
352
|
+
answers.providerName = opts.provider || detected.serviceName || PLACEHOLDER;
|
|
353
|
+
answers.specPath = opts.spec || detected.spec || PLACEHOLDER;
|
|
354
|
+
}
|
|
355
|
+
if (kind === 'consumer' || kind === 'both') {
|
|
356
|
+
answers.consumerName = opts.consumer || detected.serviceName || PLACEHOLDER;
|
|
357
|
+
answers.consumerProvider = opts.consumerProvider || PLACEHOLDER;
|
|
358
|
+
answers.contractPath = opts.contract || PLACEHOLDER;
|
|
359
|
+
answers.contractFormat = opts.format || 'OPENAPI';
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
answers.writeWorkflow = !!opts.writeWorkflow;
|
|
363
|
+
return answers;
|
|
364
|
+
}
|
|
365
|
+
|
|
329
366
|
// ─── Command ───────────────────────────────────────────────────────────────
|
|
330
367
|
|
|
331
368
|
const initCommand = new Command('init')
|
|
@@ -349,12 +386,19 @@ const initCommand = new Command('init')
|
|
|
349
386
|
const detected = detectAll(cwd);
|
|
350
387
|
|
|
351
388
|
let answers;
|
|
352
|
-
if (opts.
|
|
389
|
+
if (opts.print) {
|
|
390
|
+
// --print is documented as a dry-run that detects everything and writes
|
|
391
|
+
// a proposed YAML without prompting. Route it through the non-interactive
|
|
392
|
+
// flow (with relaxed validation — see previewFlow) so it truly never asks
|
|
393
|
+
// questions, even if the user didn't pass --no-interactive or all the
|
|
394
|
+
// required scripted-mode flags.
|
|
395
|
+
answers = previewFlow(detected, opts);
|
|
396
|
+
} else if (opts.interactive === false) {
|
|
353
397
|
// In non-interactive mode, refuse to overwrite an existing config unless
|
|
354
398
|
// --force is passed. Prevents a CI script from silently clobbering a
|
|
355
399
|
// hand-edited .specshield.yml that has settings the wizard wouldn't
|
|
356
400
|
// regenerate (custom branch, different provider name, etc.).
|
|
357
|
-
if (detected.existing && !opts.force
|
|
401
|
+
if (detected.existing && !opts.force) {
|
|
358
402
|
logger.error(
|
|
359
403
|
'.specshield.yml already exists. Pass --force to overwrite, or remove the file first.');
|
|
360
404
|
process.exit(2);
|
|
@@ -369,7 +413,15 @@ const initCommand = new Command('init')
|
|
|
369
413
|
const yaml = render(cfg);
|
|
370
414
|
|
|
371
415
|
if (opts.print) {
|
|
372
|
-
|
|
416
|
+
// Header comment makes it obvious this was a dry-run + flags any
|
|
417
|
+
// placeholders the user will need to fill in before committing.
|
|
418
|
+
process.stdout.write(
|
|
419
|
+
'\n# ─────────────────────────────────────────────────────────────\n' +
|
|
420
|
+
'# specshield init --print — DRY RUN. No files were written.\n' +
|
|
421
|
+
'# Review the YAML below; replace any "<replace-me>" placeholders\n' +
|
|
422
|
+
'# before running `specshield init` (without --print) for real.\n' +
|
|
423
|
+
'# ─────────────────────────────────────────────────────────────\n\n' +
|
|
424
|
+
yaml);
|
|
373
425
|
return;
|
|
374
426
|
}
|
|
375
427
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `specshield share` — generate a public shareable URL for a comparison
|
|
5
|
+
* report, so users can paste a SpecShield-hosted link into Slack, GitHub
|
|
6
|
+
* PR comments, or Jira tickets.
|
|
7
|
+
*
|
|
8
|
+
* Like the history command, the bigger purpose is making a concrete
|
|
9
|
+
* Cloud-only feature discoverable from `specshield --help`. Anyone reading
|
|
10
|
+
* the help output sees "share" listed and discovers there's value behind
|
|
11
|
+
* signing up.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* specshield share <report-id> # share an existing remote compare
|
|
15
|
+
* specshield share <base.yaml> <target> # compare-and-share in one step
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { Command } = require('commander');
|
|
19
|
+
const chalk = require('chalk');
|
|
20
|
+
const ora = require('ora');
|
|
21
|
+
const axios = require('axios');
|
|
22
|
+
const logger = require('../utils/logger');
|
|
23
|
+
const { getStoredApiKey } = require('../config/localConfig');
|
|
24
|
+
const { loadSpec } = require('../core/loadSpec');
|
|
25
|
+
|
|
26
|
+
const HOSTED_API_URL = 'https://specshield.io';
|
|
27
|
+
|
|
28
|
+
const share = new Command('share');
|
|
29
|
+
|
|
30
|
+
share
|
|
31
|
+
.description('Generate a public shareable URL for a comparison (Cloud account required)')
|
|
32
|
+
.argument('[reportOrBase]', 'Existing report ID, or path to base spec when comparing inline')
|
|
33
|
+
.argument('[target]', 'Path to target spec when comparing inline (optional)')
|
|
34
|
+
.option('--expires <days>', 'Make the share link expire after N days (default: never)', null)
|
|
35
|
+
.option('--api-key <key>', 'API key (overrides env / stored config)')
|
|
36
|
+
.option('--api-url <url>', 'Override hosted API base URL', HOSTED_API_URL)
|
|
37
|
+
.action(async (reportOrBase, target, opts) => {
|
|
38
|
+
const apiKey = opts.apiKey
|
|
39
|
+
|| process.env.SPECSHIELD_API_KEY
|
|
40
|
+
|| (await getStoredApiKey())
|
|
41
|
+
|| null;
|
|
42
|
+
|
|
43
|
+
if (!apiKey) {
|
|
44
|
+
printSignupNudge();
|
|
45
|
+
process.exit(2);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!reportOrBase) {
|
|
49
|
+
logger.error('Usage:\n specshield share <report-id>\n specshield share base.yaml target.yaml');
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const spinner = ora('Generating share link...').start();
|
|
54
|
+
try {
|
|
55
|
+
const headers = { 'X-Api-Key': apiKey, 'X-SpecShield-Client': 'cli' };
|
|
56
|
+
const expiresInDays = parseInt(opts.expires, 10) || null;
|
|
57
|
+
|
|
58
|
+
let reportId;
|
|
59
|
+
if (target) {
|
|
60
|
+
// Inline-compare path: run a /compare with --remote semantics to
|
|
61
|
+
// persist the result, then immediately share the resulting history
|
|
62
|
+
// row. Two API calls but keeps the backend share endpoint single-purpose.
|
|
63
|
+
spinner.text = 'Running remote comparison...';
|
|
64
|
+
const baseSpec = await loadSpec(reportOrBase);
|
|
65
|
+
const targetSpec = await loadSpec(target);
|
|
66
|
+
const compareResp = await axios.post(`${opts.apiUrl}/compare`,
|
|
67
|
+
{ baseSpec, targetSpec },
|
|
68
|
+
{ headers, timeout: 30000 });
|
|
69
|
+
// The /compare endpoint returns the diff; the persisted history ID is
|
|
70
|
+
// on the response (added by CompareHistoryService).
|
|
71
|
+
reportId = compareResp.data?.historyId || compareResp.data?.reportId;
|
|
72
|
+
if (!reportId) {
|
|
73
|
+
throw new Error('Comparison succeeded but no history ID was returned — cannot create share link.');
|
|
74
|
+
}
|
|
75
|
+
spinner.text = 'Generating share link...';
|
|
76
|
+
} else {
|
|
77
|
+
reportId = reportOrBase;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const response = await axios.post(`${opts.apiUrl}/me/share-links`,
|
|
81
|
+
{ reportId, expiresInDays },
|
|
82
|
+
{ headers, timeout: 15000 });
|
|
83
|
+
spinner.stop();
|
|
84
|
+
|
|
85
|
+
const url = response.data?.url || response.data?.shareUrl;
|
|
86
|
+
const expiresAt = response.data?.expiresAt || null;
|
|
87
|
+
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(chalk.green(' ✔ Share link ready'));
|
|
90
|
+
console.log(' ─────────────────────────────────────────────────────');
|
|
91
|
+
console.log(' ' + chalk.cyan(url));
|
|
92
|
+
if (expiresAt) console.log(' ' + chalk.gray('Expires: ' + expiresAt));
|
|
93
|
+
console.log('');
|
|
94
|
+
console.log(chalk.gray(' Anyone with this link can view the diff — no SpecShield account required.'));
|
|
95
|
+
console.log('');
|
|
96
|
+
} catch (err) {
|
|
97
|
+
spinner.fail('Could not generate share link');
|
|
98
|
+
const msg = err.response
|
|
99
|
+
? `${err.response.status}: ${JSON.stringify(err.response.data)}`
|
|
100
|
+
: err.message;
|
|
101
|
+
logger.error(`Share failed: ${msg}`);
|
|
102
|
+
process.exit(2);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function printSignupNudge() {
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(chalk.bold(' specshield share') + chalk.gray(' — Cloud feature'));
|
|
109
|
+
console.log('');
|
|
110
|
+
console.log(' Shareable report URLs let you paste a diff into Slack, GitHub PR comments,');
|
|
111
|
+
console.log(' or Jira tickets — anyone can view without a SpecShield account.');
|
|
112
|
+
console.log('');
|
|
113
|
+
console.log(chalk.bold(' Get started in 30 seconds:'));
|
|
114
|
+
console.log(' ' + chalk.cyan('specshield login') + chalk.gray(' # API key from https://specshield.io'));
|
|
115
|
+
console.log(' ' + chalk.gray('or visit https://specshield.io and sign in with GitHub / Google'));
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(' ' + chalk.gray('Cloud is free for personal use.'));
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = share;
|
|
@@ -21,6 +21,11 @@ const BREAKING_TYPES = new Set([
|
|
|
21
21
|
'REQUEST_TYPE_CHANGED',
|
|
22
22
|
'RESPONSE_TYPE_CHANGED',
|
|
23
23
|
'SCHEMA_REMOVED',
|
|
24
|
+
// Constraint tightening: previously-valid values become invalid → breaking.
|
|
25
|
+
'CONSTRAINT_TIGHTENED',
|
|
26
|
+
// Pattern changes are treated as breaking (semantic safety: we can't
|
|
27
|
+
// tell whether the new pattern accepts a superset of the old).
|
|
28
|
+
'CONSTRAINT_PATTERN_CHANGED',
|
|
24
29
|
]);
|
|
25
30
|
|
|
26
31
|
const ADDITION_TYPES = new Set([
|
|
@@ -36,6 +41,8 @@ const ADDITION_TYPES = new Set([
|
|
|
36
41
|
const MODIFICATION_TYPES = new Set([
|
|
37
42
|
'FIELD_BECAME_OPTIONAL',
|
|
38
43
|
'PARAMETER_BECAME_OPTIONAL',
|
|
44
|
+
// Constraint relaxation: previously-rejected values now valid → safe change.
|
|
45
|
+
'CONSTRAINT_RELAXED',
|
|
39
46
|
]);
|
|
40
47
|
|
|
41
48
|
const WARNING_TYPES = new Set([
|
|
@@ -83,9 +90,120 @@ function classifyChanges(diffs) {
|
|
|
83
90
|
}
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
// Dedupe $ref-driven changes. When a schema property is added/removed/typed,
|
|
94
|
+
// the change appears once per endpoint that references the schema — which
|
|
95
|
+
// produces "6 breaking changes" reports when really one schema field was
|
|
96
|
+
// removed and it rippled through 4 endpoints. Group entries with the same
|
|
97
|
+
// (type, leafFieldName) and collapse them into one entry that names every
|
|
98
|
+
// affected endpoint. See `mergeDuplicateFieldChanges` for the rules.
|
|
99
|
+
result.breakingChanges = mergeDuplicateFieldChanges(result.breakingChanges);
|
|
100
|
+
result.additions = mergeDuplicateFieldChanges(result.additions);
|
|
101
|
+
result.modifications = mergeDuplicateFieldChanges(result.modifications);
|
|
102
|
+
result.warnings = mergeDuplicateFieldChanges(result.warnings);
|
|
103
|
+
|
|
86
104
|
return result;
|
|
87
105
|
}
|
|
88
106
|
|
|
107
|
+
// Types whose multi-counting is almost always caused by a $ref'd component
|
|
108
|
+
// schema being inlined into many endpoint responses/requests. Safe to dedupe.
|
|
109
|
+
const FIELD_DEDUPE_TYPES = new Set([
|
|
110
|
+
'RESPONSE_FIELD_REMOVED',
|
|
111
|
+
'RESPONSE_FIELD_ADDED',
|
|
112
|
+
'RESPONSE_FIELD_TYPE_CHANGED',
|
|
113
|
+
'REQUEST_FIELD_REMOVED',
|
|
114
|
+
'REQUEST_FIELD_ADDED',
|
|
115
|
+
'REQUEST_FIELD_TYPE_CHANGED',
|
|
116
|
+
'REQUEST_REQUIRED_FIELD_ADDED',
|
|
117
|
+
'FIELD_BECAME_REQUIRED',
|
|
118
|
+
'FIELD_BECAME_OPTIONAL',
|
|
119
|
+
'ENUM_VALUE_REMOVED',
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the leaf field name from a dotted/bracketed field path so we can
|
|
124
|
+
* dedupe by component-property name rather than full positional path.
|
|
125
|
+
*
|
|
126
|
+
* responses.200.data[items].legacy_id → legacy_id
|
|
127
|
+
* responses.201.legacy_id → legacy_id
|
|
128
|
+
* requestBody.email → email
|
|
129
|
+
*
|
|
130
|
+
* Both rows above share leaf "legacy_id", so they're recognised as the same
|
|
131
|
+
* schema-level change.
|
|
132
|
+
*/
|
|
133
|
+
function leafFieldName(field) {
|
|
134
|
+
if (!field) return null;
|
|
135
|
+
const parts = field.split('.');
|
|
136
|
+
const last = parts[parts.length - 1];
|
|
137
|
+
// Strip trailing array marker like "data[items]" → "data"
|
|
138
|
+
return last.replace(/\[.*$/, '');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Collapses entries that have the same (type, leafFieldName) into one entry
|
|
143
|
+
* with an `affectedEndpoints` array of every `${METHOD} ${path}` it appeared
|
|
144
|
+
* under. The original first-seen entry is kept as the canonical record; its
|
|
145
|
+
* description is rewritten to lead with the field name and end with the
|
|
146
|
+
* affected-endpoint count.
|
|
147
|
+
*
|
|
148
|
+
* Non-field types (ENDPOINT_*, METHOD_*, SCHEMA_*, PARAMETER_*) pass through
|
|
149
|
+
* untouched — they're already at the right granularity.
|
|
150
|
+
*/
|
|
151
|
+
function mergeDuplicateFieldChanges(entries) {
|
|
152
|
+
const groups = new Map();
|
|
153
|
+
const passthrough = [];
|
|
154
|
+
|
|
155
|
+
for (const change of entries) {
|
|
156
|
+
if (!FIELD_DEDUPE_TYPES.has(change.type) || !change.field) {
|
|
157
|
+
passthrough.push(change);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const leaf = leafFieldName(change.field);
|
|
161
|
+
const key = `${change.type}::${leaf}`;
|
|
162
|
+
if (!groups.has(key)) {
|
|
163
|
+
groups.set(key, { canonical: { ...change }, endpoints: [] });
|
|
164
|
+
}
|
|
165
|
+
if (change.path && change.method) {
|
|
166
|
+
groups.get(key).endpoints.push(`${change.method.toUpperCase()} ${change.path}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const merged = [];
|
|
171
|
+
for (const { canonical, endpoints } of groups.values()) {
|
|
172
|
+
if (endpoints.length <= 1) {
|
|
173
|
+
// Single occurrence — keep the original detailed description.
|
|
174
|
+
merged.push(canonical);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const leaf = leafFieldName(canonical.field);
|
|
178
|
+
canonical.affectedEndpoints = endpoints;
|
|
179
|
+
canonical.description = describeMergedChange(canonical.type, leaf, endpoints);
|
|
180
|
+
// Strip path/method from the canonical entry since it now applies to many.
|
|
181
|
+
canonical.path = null;
|
|
182
|
+
canonical.method = null;
|
|
183
|
+
merged.push(canonical);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return [...merged, ...passthrough];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function describeMergedChange(type, leaf, endpoints) {
|
|
190
|
+
const n = endpoints.length;
|
|
191
|
+
const VERBS = {
|
|
192
|
+
RESPONSE_FIELD_REMOVED: `Response field "${leaf}" was removed`,
|
|
193
|
+
RESPONSE_FIELD_ADDED: `Response field "${leaf}" was added`,
|
|
194
|
+
RESPONSE_FIELD_TYPE_CHANGED: `Response field "${leaf}" changed type`,
|
|
195
|
+
REQUEST_FIELD_REMOVED: `Request field "${leaf}" was removed`,
|
|
196
|
+
REQUEST_FIELD_ADDED: `Request field "${leaf}" was added`,
|
|
197
|
+
REQUEST_FIELD_TYPE_CHANGED: `Request field "${leaf}" changed type`,
|
|
198
|
+
REQUEST_REQUIRED_FIELD_ADDED: `Required request field "${leaf}" was added`,
|
|
199
|
+
FIELD_BECAME_REQUIRED: `Field "${leaf}" became required`,
|
|
200
|
+
FIELD_BECAME_OPTIONAL: `Field "${leaf}" became optional`,
|
|
201
|
+
ENUM_VALUE_REMOVED: `Enum value removed from "${leaf}"`,
|
|
202
|
+
};
|
|
203
|
+
const head = VERBS[type] || `Change in "${leaf}"`;
|
|
204
|
+
return `${head} (affects ${n} endpoint${n === 1 ? '' : 's'}: ${endpoints.join(', ')})`;
|
|
205
|
+
}
|
|
206
|
+
|
|
89
207
|
/**
|
|
90
208
|
* Filter a classified result to only include changes at or above minSeverity.
|
|
91
209
|
* info < warning < error
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contextual signup nudge after a successful `specshield compare`.
|
|
5
|
+
*
|
|
6
|
+
* Lives in its own module so the heuristic is easy to read, test, and tune
|
|
7
|
+
* without touching the compare command. The default state file path is
|
|
8
|
+
* `~/.specshield/state.json` — same directory as the stored API key so the
|
|
9
|
+
* "is the user logged in?" check is co-located.
|
|
10
|
+
*
|
|
11
|
+
* Trigger conditions (ALL must be true):
|
|
12
|
+
* - User is NOT logged in
|
|
13
|
+
* - Not in CI (env CI/GITHUB_ACTIONS/etc)
|
|
14
|
+
* - Output isn't `--json` (machine-readable)
|
|
15
|
+
* - User has run >= 3 compares in the last 7 days
|
|
16
|
+
* - We haven't shown a prompt in the last 7 days (don't spam)
|
|
17
|
+
*
|
|
18
|
+
* The state file is best-effort; if the disk read or write fails for any
|
|
19
|
+
* reason we silently skip the prompt — we will never block or fail the
|
|
20
|
+
* compare command for the sake of a marketing nudge.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
const fsExtra = require('fs-extra');
|
|
26
|
+
const chalk = require('chalk');
|
|
27
|
+
|
|
28
|
+
// Path is overridable for tests. Production never sets the env var, so the
|
|
29
|
+
// real home-directory state file is used. Tests point this at a temp dir
|
|
30
|
+
// because os.homedir() on POSIX ignores HOME and reads from /etc/passwd.
|
|
31
|
+
const STATE_PATH = process.env.SPECSHIELD_STATE_PATH
|
|
32
|
+
|| path.join(os.homedir(), '.specshield', 'state.json');
|
|
33
|
+
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
34
|
+
const COMPARE_THRESHOLD = 3;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Hand-tuned variant strings keyed by the user's compare count tier. We
|
|
38
|
+
* escalate the value-prop as they prove sustained usage — first prompts
|
|
39
|
+
* are gentle (just history); deeper-use prompts are richer (BDCT, team).
|
|
40
|
+
*/
|
|
41
|
+
const VARIANTS = [
|
|
42
|
+
{
|
|
43
|
+
minCount: 3,
|
|
44
|
+
body: [
|
|
45
|
+
`${chalk.cyan('●')} ${chalk.bold('Track these comparisons over time:')}`,
|
|
46
|
+
` ${chalk.cyan('specshield login')} ${chalk.gray('# 30-sec signup via GitHub / Google · no credit card')}`,
|
|
47
|
+
` ${chalk.gray('Unlocks: compare history, shareable report URLs, PR badge')}`,
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
minCount: 10,
|
|
52
|
+
body: [
|
|
53
|
+
`${chalk.cyan('●')} ${chalk.bold('You ran 10+ compares this week.')} ${chalk.gray('Your team can collaborate on this:')}`,
|
|
54
|
+
` ${chalk.cyan('specshield login')} ${chalk.gray('# free Cloud account · GitHub PR checks · BDCT · team dashboard')}`,
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
minCount: 25,
|
|
59
|
+
body: [
|
|
60
|
+
`${chalk.cyan('●')} ${chalk.bold('Heavy usage detected.')} ${chalk.gray("Time to gate deploys with can-i-deploy:")}`,
|
|
61
|
+
` ${chalk.cyan('specshield login')} ${chalk.gray('# unlocks BDCT bi-directional contracts · can-i-deploy · audit log')}`,
|
|
62
|
+
` ${chalk.gray('Pricing: free for personal use · $29/mo Solo · $89/mo Team (10 seats)')}`,
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function isCi() {
|
|
68
|
+
const e = process.env;
|
|
69
|
+
return !!(e.CI || e.GITHUB_ACTIONS || e.BUILDKITE || e.CIRCLECI ||
|
|
70
|
+
e.GITLAB_CI || e.JENKINS_URL || e.TRAVIS || e.TF_BUILD);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readState() {
|
|
74
|
+
try {
|
|
75
|
+
return await fsExtra.readJson(STATE_PATH);
|
|
76
|
+
} catch {
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function writeState(state) {
|
|
82
|
+
try {
|
|
83
|
+
await fsExtra.outputJson(STATE_PATH, state, { spaces: 2 });
|
|
84
|
+
} catch {
|
|
85
|
+
// Best-effort — never throw from the conversion path.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record a successful compare and return the variant string to print
|
|
91
|
+
* after the regular output (or null to skip the prompt entirely).
|
|
92
|
+
*
|
|
93
|
+
* @param {object} opts
|
|
94
|
+
* @param {boolean} opts.loggedIn Whether the user has a stored API key
|
|
95
|
+
* @param {boolean} opts.jsonOutput True if --json was passed (suppress prompts)
|
|
96
|
+
* @returns {Promise<string|null>}
|
|
97
|
+
*/
|
|
98
|
+
async function recordCompareAndMaybeRender({ loggedIn, jsonOutput }) {
|
|
99
|
+
if (loggedIn) return null;
|
|
100
|
+
if (jsonOutput) return null;
|
|
101
|
+
if (isCi()) return null;
|
|
102
|
+
// Honor the same opt-out as the post-install banner.
|
|
103
|
+
if (process.env.SPECSHIELD_NO_BANNER === '1') return null;
|
|
104
|
+
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const state = await readState();
|
|
107
|
+
|
|
108
|
+
// Window the compare count: reset the counter once a week so a heavy
|
|
109
|
+
// burst followed by a long pause doesn't trigger a prompt months later.
|
|
110
|
+
if (!state.windowStartedAt || (now - state.windowStartedAt) > ONE_WEEK_MS) {
|
|
111
|
+
state.windowStartedAt = now;
|
|
112
|
+
state.compareCount = 0;
|
|
113
|
+
}
|
|
114
|
+
state.compareCount = (state.compareCount || 0) + 1;
|
|
115
|
+
|
|
116
|
+
let prompt = null;
|
|
117
|
+
if (state.compareCount >= COMPARE_THRESHOLD) {
|
|
118
|
+
const sinceLast = state.lastPromptAt ? (now - state.lastPromptAt) : Infinity;
|
|
119
|
+
if (sinceLast >= ONE_WEEK_MS) {
|
|
120
|
+
prompt = pickVariant(state.compareCount);
|
|
121
|
+
state.lastPromptAt = now;
|
|
122
|
+
state.promptsShown = (state.promptsShown || 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await writeState(state);
|
|
127
|
+
return prompt;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function pickVariant(count) {
|
|
131
|
+
// Linear scan from most-aggressive to least-aggressive — pick the
|
|
132
|
+
// highest-min-count variant the user qualifies for.
|
|
133
|
+
const eligible = VARIANTS.filter(v => count >= v.minCount).sort((a, b) => b.minCount - a.minCount);
|
|
134
|
+
if (eligible.length === 0) return null;
|
|
135
|
+
return eligible[0].body.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { recordCompareAndMaybeRender, STATE_PATH };
|
package/src/core/diffEngine.js
CHANGED
|
@@ -124,6 +124,11 @@ function diffParameters(path, method, baseParams, targetParams, diffs) {
|
|
|
124
124
|
description: `Parameter "${bp.name}" became ${tp.required ? 'required' : 'optional'} in ${method.toUpperCase()} ${path}`,
|
|
125
125
|
});
|
|
126
126
|
}
|
|
127
|
+
|
|
128
|
+
// Constraint changes on the parameter's schema (min/max, length,
|
|
129
|
+
// pattern, enum). Tightening = breaking; loosening = modification.
|
|
130
|
+
diffConstraints(
|
|
131
|
+
path, method, `parameters.${bp.name}`, bp.schema, tp.schema, diffs);
|
|
127
132
|
}
|
|
128
133
|
}
|
|
129
134
|
|
|
@@ -260,6 +265,9 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
|
|
|
260
265
|
// Enum changes on field
|
|
261
266
|
diffEnums(path, method, fullField, bField.enum, tField.enum, diffs);
|
|
262
267
|
|
|
268
|
+
// Constraint changes on the field's schema (min/max, length, pattern).
|
|
269
|
+
diffConstraints(path, method, fullField, bField, tField, diffs);
|
|
270
|
+
|
|
263
271
|
// Recurse into nested objects
|
|
264
272
|
if (bField.properties || tField.properties) {
|
|
265
273
|
diffSchemaNode(path, method, fullField, bField, tField, diffs, isRequest);
|
|
@@ -317,6 +325,97 @@ function diffSchemaNode(path, method, fieldPrefix, base, target, diffs, isReques
|
|
|
317
325
|
}
|
|
318
326
|
}
|
|
319
327
|
|
|
328
|
+
// ─── Constraints (min/max, length, pattern) ─────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Detects changes to numeric/string constraint fields on a schema node.
|
|
332
|
+
* Classification is direction-aware:
|
|
333
|
+
*
|
|
334
|
+
* maximum increased / minimum decreased / maxLength increased / etc.
|
|
335
|
+
* → CONSTRAINT_RELAXED (modification — existing clients still valid)
|
|
336
|
+
*
|
|
337
|
+
* maximum decreased / minimum increased / maxLength decreased / etc.
|
|
338
|
+
* → CONSTRAINT_TIGHTENED (breaking — previously-valid values now rejected)
|
|
339
|
+
*
|
|
340
|
+
* pattern added/changed/removed
|
|
341
|
+
* → CONSTRAINT_PATTERN_CHANGED (breaking; semantic comparison is too hard
|
|
342
|
+
* to do safely so we treat any change as potentially restrictive)
|
|
343
|
+
*
|
|
344
|
+
* `null` on either side means "not constrained" — adding a constraint is
|
|
345
|
+
* tightening, removing one is relaxing.
|
|
346
|
+
*/
|
|
347
|
+
function diffConstraints(path, method, fieldPrefix, base, target, diffs) {
|
|
348
|
+
if (!base || !target) return;
|
|
349
|
+
|
|
350
|
+
// Direction map: how to interpret a numeric change for each constraint.
|
|
351
|
+
// 'upper' constraints (maximum, maxLength, maxItems): higher = looser.
|
|
352
|
+
// 'lower' constraints (minimum, minLength, minItems): lower = looser.
|
|
353
|
+
const UPPER = ['maximum', 'maxLength', 'maxItems'];
|
|
354
|
+
const LOWER = ['minimum', 'minLength', 'minItems'];
|
|
355
|
+
|
|
356
|
+
for (const key of UPPER) {
|
|
357
|
+
pushNumericConstraint(path, method, fieldPrefix, key, base[key], target[key], 'upper', diffs);
|
|
358
|
+
}
|
|
359
|
+
for (const key of LOWER) {
|
|
360
|
+
pushNumericConstraint(path, method, fieldPrefix, key, base[key], target[key], 'lower', diffs);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// pattern: any change is treated as a tightening (breaking). Adding or
|
|
364
|
+
// removing a pattern also counts.
|
|
365
|
+
if ((base.pattern || null) !== (target.pattern || null)) {
|
|
366
|
+
diffs.push({
|
|
367
|
+
type: 'CONSTRAINT_PATTERN_CHANGED',
|
|
368
|
+
path, method, field: fieldPrefix,
|
|
369
|
+
oldValue: base.pattern || null,
|
|
370
|
+
newValue: target.pattern || null,
|
|
371
|
+
description: target.pattern
|
|
372
|
+
? `Pattern constraint on "${fieldPrefix}" changed from ${base.pattern ? `/${base.pattern}/` : '(none)'} to /${target.pattern}/ in ${method.toUpperCase()} ${path}`
|
|
373
|
+
: `Pattern constraint on "${fieldPrefix}" was removed from ${method.toUpperCase()} ${path}`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function pushNumericConstraint(path, method, field, key, oldVal, newVal, direction, diffs) {
|
|
379
|
+
// Treat null/undefined as "no constraint".
|
|
380
|
+
const had = oldVal !== null && oldVal !== undefined;
|
|
381
|
+
const has = newVal !== null && newVal !== undefined;
|
|
382
|
+
|
|
383
|
+
if (!had && !has) return;
|
|
384
|
+
if (had && has && oldVal === newVal) return;
|
|
385
|
+
|
|
386
|
+
// Adding a constraint where none existed → tightening (breaking).
|
|
387
|
+
if (!had && has) {
|
|
388
|
+
diffs.push({
|
|
389
|
+
type: 'CONSTRAINT_TIGHTENED',
|
|
390
|
+
path, method, field,
|
|
391
|
+
oldValue: null, newValue: String(newVal),
|
|
392
|
+
description: `${key} constraint added on "${field}" (now ${newVal}) — tightens "${method.toUpperCase()} ${path}"`,
|
|
393
|
+
});
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// Removing a constraint → relaxation (modification).
|
|
397
|
+
if (had && !has) {
|
|
398
|
+
diffs.push({
|
|
399
|
+
type: 'CONSTRAINT_RELAXED',
|
|
400
|
+
path, method, field,
|
|
401
|
+
oldValue: String(oldVal), newValue: null,
|
|
402
|
+
description: `${key} constraint removed from "${field}" — relaxes "${method.toUpperCase()} ${path}"`,
|
|
403
|
+
});
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Both present, different value. Direction tells us whether higher = looser
|
|
408
|
+
// or higher = tighter for THIS constraint key.
|
|
409
|
+
const wentUp = newVal > oldVal;
|
|
410
|
+
const isRelaxation = (direction === 'upper' && wentUp) || (direction === 'lower' && !wentUp);
|
|
411
|
+
diffs.push({
|
|
412
|
+
type: isRelaxation ? 'CONSTRAINT_RELAXED' : 'CONSTRAINT_TIGHTENED',
|
|
413
|
+
path, method, field,
|
|
414
|
+
oldValue: String(oldVal), newValue: String(newVal),
|
|
415
|
+
description: `${key} on "${field}" changed from ${oldVal} to ${newVal} (${isRelaxation ? 'relaxed' : 'tightened'}) in ${method.toUpperCase()} ${path}`,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
320
419
|
function diffEnums(path, method, fieldPrefix, baseEnum, targetEnum, diffs) {
|
|
321
420
|
if (!baseEnum || !targetEnum) return;
|
|
322
421
|
for (const val of baseEnum) {
|
|
@@ -119,6 +119,18 @@ function resolveSchema(schema, schemas, depth = 0) {
|
|
|
119
119
|
required: Array.isArray(schema.required) ? schema.required : [],
|
|
120
120
|
properties: {},
|
|
121
121
|
items: null,
|
|
122
|
+
// Constraint fields — preserved so the diff engine can detect changes
|
|
123
|
+
// to ranges/patterns/lengths (e.g. `maximum: 100` → `maximum: 250`).
|
|
124
|
+
// `undefined` (not present) and `null` are treated as "no constraint"
|
|
125
|
+
// by the diff engine; the explicit values flow through unchanged.
|
|
126
|
+
minimum: schema.minimum !== undefined ? schema.minimum : null,
|
|
127
|
+
maximum: schema.maximum !== undefined ? schema.maximum : null,
|
|
128
|
+
minLength: schema.minLength !== undefined ? schema.minLength : null,
|
|
129
|
+
maxLength: schema.maxLength !== undefined ? schema.maxLength : null,
|
|
130
|
+
minItems: schema.minItems !== undefined ? schema.minItems : null,
|
|
131
|
+
maxItems: schema.maxItems !== undefined ? schema.maxItems : null,
|
|
132
|
+
pattern: schema.pattern || null,
|
|
133
|
+
multipleOf: schema.multipleOf !== undefined ? schema.multipleOf : null,
|
|
122
134
|
};
|
|
123
135
|
|
|
124
136
|
if (schema.properties) {
|
package/src/core/parseSpec.js
CHANGED
|
@@ -5,23 +5,49 @@ const path = require('path');
|
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Parse raw spec content (YAML or JSON) into a JavaScript object.
|
|
8
|
-
* Detects format from file extension or content.
|
|
8
|
+
* Detects format from file extension or content. Validates that the parsed
|
|
9
|
+
* object looks like an OpenAPI 3.x or Swagger 2.x spec — otherwise a file
|
|
10
|
+
* containing arbitrary YAML/JSON would silently succeed with "No changes
|
|
11
|
+
* detected" instead of erroring out.
|
|
9
12
|
*/
|
|
10
13
|
function parseSpec(content, filePath) {
|
|
11
14
|
const ext = filePath ? path.extname(filePath).toLowerCase() : '';
|
|
12
15
|
|
|
16
|
+
let parsed;
|
|
13
17
|
try {
|
|
14
18
|
if (ext === '.json') {
|
|
15
|
-
|
|
19
|
+
parsed = parseJson(content);
|
|
16
20
|
} else if (ext === '.yaml' || ext === '.yml') {
|
|
17
|
-
|
|
21
|
+
parsed = parseYaml(content);
|
|
18
22
|
} else {
|
|
19
23
|
// Auto-detect: try JSON first, then YAML
|
|
20
|
-
|
|
24
|
+
parsed = autoDetect(content);
|
|
21
25
|
}
|
|
22
26
|
} catch (err) {
|
|
23
27
|
throw new Error(`Failed to parse spec "${filePath}": ${err.message}`);
|
|
24
28
|
}
|
|
29
|
+
|
|
30
|
+
assertLooksLikeOpenApi(parsed, filePath);
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Confirms the parsed object has a top-level `openapi: "3.x"` or `swagger: "..."`
|
|
36
|
+
* key — the minimum surface that defines an OpenAPI/Swagger document. Without
|
|
37
|
+
* this check, a stray YAML/JSON file would silently compare as identical to
|
|
38
|
+
* anything that doesn't share its incidental keys.
|
|
39
|
+
*/
|
|
40
|
+
function assertLooksLikeOpenApi(parsed, filePath) {
|
|
41
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
42
|
+
throw new Error(`"${filePath}" is not a valid OpenAPI/Swagger spec (parsed value was not an object)`);
|
|
43
|
+
}
|
|
44
|
+
const isOpenApi3 = typeof parsed.openapi === 'string' && parsed.openapi.startsWith('3.');
|
|
45
|
+
const isSwagger2 = typeof parsed.swagger === 'string';
|
|
46
|
+
if (!isOpenApi3 && !isSwagger2) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`"${filePath}" is not a valid OpenAPI/Swagger spec ` +
|
|
49
|
+
'(missing top-level "openapi: 3.x" or "swagger: ..." key)');
|
|
50
|
+
}
|
|
25
51
|
}
|
|
26
52
|
|
|
27
53
|
function parseJson(content) {
|
|
@@ -101,11 +101,16 @@ function detectServiceName(cwd) {
|
|
|
101
101
|
const m = cargo.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
|
|
102
102
|
if (m) return { source: 'Cargo.toml', name: m[1] };
|
|
103
103
|
}
|
|
104
|
-
// pom.xml — naive
|
|
104
|
+
// pom.xml — naive artifactId match. We strip <parent>...</parent> first
|
|
105
|
+
// because every Spring Boot project has <parent><artifactId>spring-boot-starter-parent
|
|
106
|
+
// </artifactId></parent> ABOVE the project's own <artifactId>, and a plain
|
|
107
|
+
// regex match would pick the parent's name. With the parent block removed,
|
|
108
|
+
// the first <artifactId> we find is the project's own.
|
|
105
109
|
const pom = readText(path.join(cwd, 'pom.xml'));
|
|
106
110
|
if (pom) {
|
|
107
|
-
const
|
|
108
|
-
|
|
111
|
+
const stripped = pom.replace(/<parent>[\s\S]*?<\/parent>/g, '');
|
|
112
|
+
const m = stripped.match(/<artifactId>([^<]+)<\/artifactId>/);
|
|
113
|
+
if (m) return { source: 'pom.xml', name: m[1].trim() };
|
|
109
114
|
}
|
|
110
115
|
// Fallback — directory name
|
|
111
116
|
return { source: 'directory', name: path.basename(path.resolve(cwd)) };
|