specshield 3.0.0 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render `.specshield.yml` from a structured config object.
5
+ *
6
+ * Hand-rolled YAML so we can interleave comments. The shape is small and
7
+ * stable; using `js-yaml.dump` would give us a clean file but no comments —
8
+ * comments are 80% of the value of the file for users reading it later.
9
+ *
10
+ * Schema (all keys optional except where noted):
11
+ *
12
+ * {
13
+ * schemaVersion: 1,
14
+ * failOnBreaking: true,
15
+ * severity: 'error',
16
+ * bdct: {
17
+ * org: 'acme-pay', // required
18
+ * server: 'https://specshield.io',
19
+ * environment: 'staging',
20
+ * provider: { name, spec, branch },
21
+ * consumer: { name, provider, contract, format }
22
+ * },
23
+ * github: { specPath, failOnBreaking, commentOnPr }
24
+ * }
25
+ */
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+
30
+ const HEADER = [
31
+ '# .specshield.yml — generated by `specshield init`',
32
+ '#',
33
+ '# Picked up automatically by every `specshield` invocation in this directory.',
34
+ '# CLI flags always override values in this file.',
35
+ '# Safe to commit. Never put your API key here — keep it in ~/.specshield/config.json',
36
+ '# or set SPECSHIELD_API_KEY in CI.',
37
+ '',
38
+ ];
39
+
40
+ function quoteIfNeeded(v) {
41
+ if (v === null || v === undefined) return null;
42
+ const s = String(v);
43
+ if (/^[\w./@:-]+$/.test(s)) return s; // safe bare scalar
44
+ return JSON.stringify(s); // double-quoted, JSON is YAML-safe
45
+ }
46
+
47
+ function emit(lines, indent, key, value) {
48
+ if (value === null || value === undefined || value === '') return;
49
+ lines.push(`${' '.repeat(indent)}${key}: ${quoteIfNeeded(value)}`);
50
+ }
51
+
52
+ function emitBoolean(lines, indent, key, value) {
53
+ if (value === null || value === undefined) return;
54
+ lines.push(`${' '.repeat(indent)}${key}: ${value ? 'true' : 'false'}`);
55
+ }
56
+
57
+ function emitComment(lines, indent, text) {
58
+ lines.push(`${' '.repeat(indent)}# ${text}`);
59
+ }
60
+
61
+ function blank(lines) {
62
+ if (lines[lines.length - 1] !== '') lines.push('');
63
+ }
64
+
65
+ /**
66
+ * Build the YAML string. Returns the final document including a trailing newline.
67
+ */
68
+ function render(config = {}) {
69
+ const lines = [...HEADER];
70
+
71
+ emit(lines, 0, 'schemaVersion', config.schemaVersion ?? 1);
72
+ blank(lines);
73
+
74
+ // ── Local compare defaults ────────────────────────────────────────────
75
+ emitComment(lines, 0, 'Local compare defaults — used by `specshield compare`.');
76
+ emitBoolean(lines, 0, 'failOnBreaking', config.failOnBreaking ?? true);
77
+ emit (lines, 0, 'severity', config.severity ?? 'error');
78
+ blank(lines);
79
+
80
+ // ── BDCT section ──────────────────────────────────────────────────────
81
+ if (config.bdct && Object.keys(config.bdct).length > 0) {
82
+ emitComment(lines, 0, 'BDCT defaults — used by every `specshield bdct ...` command.');
83
+ lines.push('bdct:');
84
+ emit(lines, 2, 'org', config.bdct.org);
85
+ if (config.bdct.server && config.bdct.server !== 'https://specshield.io') {
86
+ emit(lines, 2, 'server', config.bdct.server);
87
+ }
88
+ emit(lines, 2, 'environment', config.bdct.environment);
89
+
90
+ if (config.bdct.provider) {
91
+ blank(lines);
92
+ emitComment(lines, 2, 'This project publishes a provider spec.');
93
+ lines.push(' provider:');
94
+ emit(lines, 4, 'name', config.bdct.provider.name);
95
+ emit(lines, 4, 'spec', config.bdct.provider.spec);
96
+ emit(lines, 4, 'branch', config.bdct.provider.branch);
97
+ }
98
+
99
+ if (config.bdct.consumer) {
100
+ blank(lines);
101
+ emitComment(lines, 2, 'This project publishes a consumer contract.');
102
+ lines.push(' consumer:');
103
+ emit(lines, 4, 'name', config.bdct.consumer.name);
104
+ emit(lines, 4, 'provider', config.bdct.consumer.provider);
105
+ emit(lines, 4, 'contract', config.bdct.consumer.contract);
106
+ emit(lines, 4, 'format', config.bdct.consumer.format ?? 'OPENAPI');
107
+ }
108
+ blank(lines);
109
+ }
110
+
111
+ // ── GitHub App defaults ───────────────────────────────────────────────
112
+ if (config.github && Object.keys(config.github).length > 0) {
113
+ emitComment(lines, 0, 'GitHub App + bdct-action defaults.');
114
+ lines.push('github:');
115
+ emit (lines, 2, 'specPath', config.github.specPath);
116
+ emitBoolean(lines, 2, 'failOnBreaking', config.github.failOnBreaking);
117
+ emitBoolean(lines, 2, 'commentOnPr', config.github.commentOnPr);
118
+ blank(lines);
119
+ }
120
+
121
+ // Trim trailing blank lines, ensure exactly one trailing newline.
122
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
123
+ return lines.join('\n') + '\n';
124
+ }
125
+
126
+ /**
127
+ * Write the rendered YAML to `<cwd>/.specshield.yml`. Creates parent dirs
128
+ * if needed (it shouldn't, but defensive). Returns the absolute path written.
129
+ */
130
+ function writeProjectConfig(config, cwd = process.cwd()) {
131
+ const target = path.join(cwd, '.specshield.yml');
132
+ fs.mkdirSync(path.dirname(target), { recursive: true });
133
+ fs.writeFileSync(target, render(config), 'utf8');
134
+ return target;
135
+ }
136
+
137
+ /**
138
+ * Render a starter GitHub Actions workflow that uses
139
+ * `specshield26/bdct-action@v1`. Optional output of the wizard.
140
+ */
141
+ function renderWorkflow({ kind, providerName, consumerName, providerForConsumer }) {
142
+ const isProvider = kind === 'provider' || kind === 'both';
143
+ const isConsumer = kind === 'consumer' || kind === 'both';
144
+
145
+ const lines = [
146
+ '# .github/workflows/specshield-bdct.yml',
147
+ '# Generated by `specshield init`. Edit freely.',
148
+ 'name: SpecShield BDCT',
149
+ '',
150
+ 'on:',
151
+ ' push:',
152
+ ' branches: [main]',
153
+ '',
154
+ 'jobs:',
155
+ ];
156
+
157
+ if (isProvider) {
158
+ lines.push(
159
+ ' publish-provider:',
160
+ ' runs-on: ubuntu-latest',
161
+ ' steps:',
162
+ ' - uses: actions/checkout@v4',
163
+ ' - uses: specshield26/bdct-action@v1',
164
+ ' with:',
165
+ ' command: publish-provider',
166
+ ` provider: ${providerName}`,
167
+ ' version: ${{ github.sha }}',
168
+ ' spec: api/openapi.yaml',
169
+ ' env: production',
170
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
171
+ '',
172
+ ' gate:',
173
+ ' needs: publish-provider',
174
+ ' runs-on: ubuntu-latest',
175
+ ' steps:',
176
+ ' - uses: specshield26/bdct-action@v1',
177
+ ' with:',
178
+ ' command: can-i-deploy',
179
+ ` service: ${providerName}`,
180
+ ' version: ${{ github.sha }}',
181
+ ' env: production',
182
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
183
+ '',
184
+ );
185
+ }
186
+
187
+ if (isConsumer) {
188
+ lines.push(
189
+ ' publish-consumer:',
190
+ ' runs-on: ubuntu-latest',
191
+ ' steps:',
192
+ ' - uses: actions/checkout@v4',
193
+ ' - uses: specshield26/bdct-action@v1',
194
+ ' with:',
195
+ ' command: publish-consumer',
196
+ ` consumer: ${consumerName}`,
197
+ ` provider: ${providerForConsumer}`,
198
+ ' version: ${{ github.sha }}',
199
+ ' contract: contracts/contract.yaml',
200
+ ' api-token: ${{ secrets.SPECSHIELD_API_KEY }}',
201
+ '',
202
+ );
203
+ }
204
+
205
+ return lines.join('\n');
206
+ }
207
+
208
+ function writeWorkflow(spec, cwd = process.cwd()) {
209
+ const dir = path.join(cwd, '.github', 'workflows');
210
+ const target = path.join(dir, 'specshield-bdct.yml');
211
+ fs.mkdirSync(dir, { recursive: true });
212
+ fs.writeFileSync(target, renderWorkflow(spec), 'utf8');
213
+ return target;
214
+ }
215
+
216
+ module.exports = {
217
+ render,
218
+ writeProjectConfig,
219
+ renderWorkflow,
220
+ writeWorkflow,
221
+ };
@@ -0,0 +1,189 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Load `.specshield.yml` from the working directory (or any ancestor) and
5
+ * apply its defaults to a parsed CLI options object.
6
+ *
7
+ * Precedence at call time:
8
+ * CLI flag > env var > .specshield.yml > hard-coded default
9
+ *
10
+ * Each `bdct` subcommand calls `applyBdctDefaults(opts, command)` after
11
+ * parsing args. Required-field validation happens here too — that way the
12
+ * CLI flags can stop being `requiredOption(...)` and fall through gracefully
13
+ * to the project config when the user is running from a configured repo.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const yaml = require('js-yaml');
19
+ const logger = require('../utils/logger');
20
+
21
+ /**
22
+ * Walk upward from `start` looking for `.specshield.yml` or `.specshield.yaml`.
23
+ * Returns the absolute path of the first hit, or null.
24
+ */
25
+ function findProjectConfigFile(start = process.cwd()) {
26
+ let dir = path.resolve(start);
27
+ // Bound walk to a sensible depth — never escape the user's home directory.
28
+ for (let i = 0; i < 20; i++) {
29
+ for (const name of ['.specshield.yml', '.specshield.yaml']) {
30
+ const candidate = path.join(dir, name);
31
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
32
+ return candidate;
33
+ }
34
+ }
35
+ const parent = path.dirname(dir);
36
+ if (parent === dir) return null;
37
+ dir = parent;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ let cached = null;
43
+ let cachedFor = null;
44
+
45
+ /**
46
+ * Returns the parsed config object (or an empty `{}` if no file is found).
47
+ * Cached per-process for the same starting directory; tests can pass
48
+ * `{ noCache: true }` or call `clearCache()` to refetch.
49
+ */
50
+ function loadProjectConfig(start = process.cwd(), { noCache = false } = {}) {
51
+ if (!noCache && cached !== null && cachedFor === start) return cached;
52
+
53
+ const file = findProjectConfigFile(start);
54
+ if (!file) {
55
+ cached = {}; cachedFor = start;
56
+ return cached;
57
+ }
58
+
59
+ let parsed = {};
60
+ try {
61
+ const raw = fs.readFileSync(file, 'utf8');
62
+ parsed = yaml.load(raw) || {};
63
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ logger.warn(`Ignoring ${file}: top-level must be a YAML mapping.`);
65
+ parsed = {};
66
+ }
67
+ } catch (err) {
68
+ logger.warn(`Failed to read ${file}: ${err.message}. Continuing without project config.`);
69
+ parsed = {};
70
+ }
71
+
72
+ cached = { ...parsed, _file: file };
73
+ cachedFor = start;
74
+ return cached;
75
+ }
76
+
77
+ function clearCache() { cached = null; cachedFor = null; }
78
+
79
+ /**
80
+ * Required-field declaration per BDCT subcommand. Same source-of-truth the
81
+ * old `requiredOption(...)` checks used; centralised here so missing fields
82
+ * produce a single, friendly error.
83
+ */
84
+ const REQUIRED_FIELDS = {
85
+ 'publish-provider': ['org', 'provider', 'version', 'spec'],
86
+ 'publish-consumer': ['org', 'consumer', 'provider', 'version', 'contract'],
87
+ 'verify': ['org', 'consumer', 'consumerVersion', 'provider', 'providerVersion'],
88
+ 'can-i-deploy': ['org', 'service', 'version'],
89
+ 'matrix': ['org'],
90
+ 'list': ['org'],
91
+ 'list-providers': ['org'],
92
+ 'list-consumers': ['org'],
93
+ };
94
+
95
+ /**
96
+ * Map a CLI option name to a path inside `.specshield.yml > bdct`.
97
+ * Returns null when there is no defaulting rule for that option.
98
+ */
99
+ function bdctDefaultFor(bdct, name, command) {
100
+ if (!bdct) return undefined;
101
+ switch (name) {
102
+ case 'org': return bdct.org;
103
+ case 'server': return bdct.server;
104
+ case 'env': return bdct.environment;
105
+
106
+ case 'provider': {
107
+ // For consumer-side operations the provider lives under `bdct.consumer.provider`.
108
+ if (command === 'publish-consumer' || command === 'verify') {
109
+ return bdct.consumer && bdct.consumer.provider;
110
+ }
111
+ // For everything else it's the project's own provider name.
112
+ return bdct.provider && bdct.provider.name;
113
+ }
114
+ case 'consumer': return bdct.consumer && bdct.consumer.name;
115
+ case 'service': {
116
+ // can-i-deploy service is whichever role the project owns.
117
+ return (bdct.provider && bdct.provider.name)
118
+ || (bdct.consumer && bdct.consumer.name);
119
+ }
120
+ case 'spec': return bdct.provider && bdct.provider.spec;
121
+ case 'contract': return bdct.consumer && bdct.consumer.contract;
122
+ case 'format': return bdct.consumer && bdct.consumer.format;
123
+ case 'branch': return bdct.provider && bdct.provider.branch;
124
+ default: return undefined;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Mutates `opts` in place: for every CLI option that is currently undefined
130
+ * (or empty string), look up a default in the loaded project config.
131
+ *
132
+ * Then verify every required field for the given `command` is present.
133
+ * Throws an Error listing all missing fields if not.
134
+ */
135
+ function applyBdctDefaults(opts, command, { cwd = process.cwd() } = {}) {
136
+ const cfg = loadProjectConfig(cwd);
137
+ const bdct = cfg.bdct;
138
+
139
+ const FIELDS = [
140
+ 'org', 'server', 'env',
141
+ 'provider', 'consumer', 'service',
142
+ 'version', 'consumerVersion', 'providerVersion',
143
+ 'spec', 'contract', 'format', 'branch',
144
+ ];
145
+
146
+ // Paths in the config file are interpreted relative to the config file's
147
+ // directory (not the CWD where the command was invoked). This lets a user
148
+ // run `specshield bdct publish-provider` from any subdirectory.
149
+ const configDir = cfg._file ? path.dirname(cfg._file) : cwd;
150
+ const resolvePath = (v) =>
151
+ !v || path.isAbsolute(v) ? v : path.resolve(configDir, v);
152
+
153
+ for (const f of FIELDS) {
154
+ if (opts[f] !== undefined && opts[f] !== '') continue;
155
+ const def = bdctDefaultFor(bdct, f, command);
156
+ if (def === undefined || def === null || def === '') continue;
157
+ opts[f] = (f === 'spec' || f === 'contract') ? resolvePath(def) : def;
158
+ }
159
+
160
+ // Required-field check.
161
+ const required = REQUIRED_FIELDS[command] || [];
162
+ const missing = required.filter(k => !opts[k]);
163
+ if (missing.length > 0) {
164
+ const flagFor = (k) => '--' + k.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
165
+ const msg = [
166
+ `Missing required ${missing.length === 1 ? 'option' : 'options'} for \`bdct ${command}\`: `
167
+ + missing.map(flagFor).join(', '),
168
+ ];
169
+ if (cfg._file) {
170
+ msg.push(`Set them as CLI flags or add them under \`bdct\` in ${cfg._file}.`);
171
+ } else {
172
+ msg.push('Pass them as CLI flags, or run `specshield init` to write a `.specshield.yml`.');
173
+ }
174
+ const err = new Error(msg.join('\n'));
175
+ err.code = 'MISSING_REQUIRED_OPTIONS';
176
+ err.missing = missing;
177
+ throw err;
178
+ }
179
+
180
+ return opts;
181
+ }
182
+
183
+ module.exports = {
184
+ findProjectConfigFile,
185
+ loadProjectConfig,
186
+ clearCache,
187
+ applyBdctDefaults,
188
+ REQUIRED_FIELDS,
189
+ };
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Detect project context for `specshield init`.
5
+ *
6
+ * Pure I/O: reads files inside `cwd`, returns a plain object.
7
+ * No prompting, no network. Each detector is independent and never throws —
8
+ * unknown values come back as `null` so callers can decide whether to
9
+ * prompt or fail.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const yaml = require('js-yaml');
15
+ const cp = require('child_process');
16
+
17
+ // Glob order matters — first hit wins for the auto-detected default,
18
+ // later hits become "alternates" the wizard can offer in a multi-select.
19
+ const SPEC_CANDIDATES = [
20
+ 'api/openapi.yaml', 'api/openapi.yml', 'api/openapi.json',
21
+ 'openapi.yaml', 'openapi.yml', 'openapi.json',
22
+ 'spec/openapi.yaml', 'spec/openapi.yml', 'spec/openapi.json',
23
+ 'docs/openapi.yaml', 'docs/openapi.yml', 'docs/openapi.json',
24
+ 'swagger.yaml', 'swagger.yml', 'swagger.json',
25
+ ];
26
+
27
+ function fileExists(p) {
28
+ try { return fs.statSync(p).isFile(); } catch { return false; }
29
+ }
30
+
31
+ function readJson(p) {
32
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
33
+ }
34
+
35
+ function readText(p) {
36
+ try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
37
+ }
38
+
39
+ function safeLoadYaml(p) {
40
+ try { return yaml.load(fs.readFileSync(p, 'utf8')); } catch { return null; }
41
+ }
42
+
43
+ /**
44
+ * Returns true if the parsed object looks like an OpenAPI 3.x or Swagger 2.x spec.
45
+ * Cheap structural check — does not validate the full schema.
46
+ */
47
+ function looksLikeOpenApi(parsed) {
48
+ if (!parsed || typeof parsed !== 'object') return false;
49
+ if (typeof parsed.openapi === 'string' && parsed.openapi.startsWith('3.')) return true;
50
+ if (typeof parsed.swagger === 'string') return true;
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Find every plausible OpenAPI spec under `cwd` from the candidate list.
56
+ * Returns absolute paths in canonical order; the first entry is the
57
+ * "best" guess (existing-file order in `SPEC_CANDIDATES`).
58
+ */
59
+ function findSpecCandidates(cwd) {
60
+ const matches = [];
61
+ for (const rel of SPEC_CANDIDATES) {
62
+ const abs = path.join(cwd, rel);
63
+ if (fileExists(abs)) {
64
+ const parsed = safeLoadYaml(abs);
65
+ matches.push({ rel, abs, valid: looksLikeOpenApi(parsed) });
66
+ }
67
+ }
68
+ return matches;
69
+ }
70
+
71
+ /**
72
+ * Best-guess service / provider name in priority order:
73
+ * package.json name (with @scope stripped)
74
+ * pyproject.toml [project] name
75
+ * go.mod module last segment
76
+ * Cargo.toml [package] name
77
+ * pom.xml <artifactId>
78
+ * directory name
79
+ */
80
+ function detectServiceName(cwd) {
81
+ // package.json
82
+ const pkg = readJson(path.join(cwd, 'package.json'));
83
+ if (pkg && typeof pkg.name === 'string') {
84
+ return { source: 'package.json', name: pkg.name.replace(/^@[^/]+\//, '') };
85
+ }
86
+ // pyproject.toml — read just enough to find the name without a TOML parser
87
+ const pyproject = readText(path.join(cwd, 'pyproject.toml'));
88
+ if (pyproject) {
89
+ const m = pyproject.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
90
+ if (m) return { source: 'pyproject.toml', name: m[1] };
91
+ }
92
+ // go.mod
93
+ const goMod = readText(path.join(cwd, 'go.mod'));
94
+ if (goMod) {
95
+ const m = goMod.match(/^module\s+(\S+)/m);
96
+ if (m) return { source: 'go.mod', name: m[1].split('/').pop() };
97
+ }
98
+ // Cargo.toml
99
+ const cargo = readText(path.join(cwd, 'Cargo.toml'));
100
+ if (cargo) {
101
+ const m = cargo.match(/^\s*name\s*=\s*["']([^"']+)["']/m);
102
+ if (m) return { source: 'Cargo.toml', name: m[1] };
103
+ }
104
+ // pom.xml — naive single-line artifactId match (good enough for detection)
105
+ const pom = readText(path.join(cwd, 'pom.xml'));
106
+ if (pom) {
107
+ const m = pom.match(/<artifactId>([^<]+)<\/artifactId>/);
108
+ if (m) return { source: 'pom.xml', name: m[1] };
109
+ }
110
+ // Fallback — directory name
111
+ return { source: 'directory', name: path.basename(path.resolve(cwd)) };
112
+ }
113
+
114
+ /**
115
+ * Read git remote + current branch. Returns `{ remote, owner, repo, branch }`
116
+ * with all fields null if the directory is not a git working tree.
117
+ */
118
+ function detectGit(cwd) {
119
+ const out = (cmd) => {
120
+ try {
121
+ return cp.execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] })
122
+ .toString().trim();
123
+ } catch { return null; }
124
+ };
125
+
126
+ const remote = out('git config --get remote.origin.url');
127
+ const branch = out('git symbolic-ref --short HEAD') || null;
128
+
129
+ let owner = null, repo = null;
130
+ if (remote) {
131
+ // Match git@host:owner/repo.git OR https://host/owner/repo(.git)?
132
+ const m = remote.match(/[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/);
133
+ if (m) { owner = m[1]; repo = m[2]; }
134
+ }
135
+ return { remote, owner, repo, branch };
136
+ }
137
+
138
+ /**
139
+ * Suggest a default environment based on the current branch.
140
+ * `main` / `master` → "production", anything else → "staging".
141
+ */
142
+ function suggestEnvironment(branch) {
143
+ if (!branch) return 'staging';
144
+ if (branch === 'main' || branch === 'master') return 'production';
145
+ return 'staging';
146
+ }
147
+
148
+ /**
149
+ * Top-level: run every detector and return one combined snapshot.
150
+ */
151
+ function detectAll(cwd = process.cwd()) {
152
+ const specs = findSpecCandidates(cwd);
153
+ const service = detectServiceName(cwd);
154
+ const git = detectGit(cwd);
155
+ const env = suggestEnvironment(git.branch);
156
+ const existing = fileExists(path.join(cwd, '.specshield.yml'))
157
+ || fileExists(path.join(cwd, '.specshield.yaml'));
158
+
159
+ return {
160
+ cwd,
161
+ specs,
162
+ spec: specs.find(s => s.valid)?.rel || specs[0]?.rel || null,
163
+ service,
164
+ serviceName: service.name,
165
+ git,
166
+ branch: git.branch,
167
+ environment: env,
168
+ existing,
169
+ };
170
+ }
171
+
172
+ module.exports = {
173
+ SPEC_CANDIDATES,
174
+ looksLikeOpenApi,
175
+ findSpecCandidates,
176
+ detectServiceName,
177
+ detectGit,
178
+ suggestEnvironment,
179
+ detectAll,
180
+ };