delimit-cli 2.3.1 → 2.4.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.
@@ -0,0 +1,109 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Delimit™ Gemini CLI Forge Adapter
4
+ * Layer: Forge (execution governance)
5
+ * Used as: command handler called from Gemini CLI hooks and @commands
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const axios = require('axios');
11
+ const AGENT_URL = `http://127.0.0.1:${process.env.DELIMIT_AGENT_PORT || 7823}`;
12
+
13
+ class DelimitGeminiForge {
14
+ constructor() {
15
+ this.id = 'delimit-forge';
16
+ this.version = '1.0.0';
17
+ }
18
+
19
+ /**
20
+ * Pre-generation: surface test failures and deploy locks to Gemini before it generates code.
21
+ */
22
+ async beforeCodeGeneration(request) {
23
+ const [testState, deployState, releaseState] = await Promise.allSettled([
24
+ axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 }),
25
+ axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 }),
26
+ axios.get(`${AGENT_URL}/release/status`, { timeout: 3000 }),
27
+ ]);
28
+
29
+ const warnings = [];
30
+
31
+ if (testState.status === 'fulfilled') {
32
+ const t = testState.value.data;
33
+ if (t?.failing > 0) warnings.push(`[FORGE] ${t.failing} test(s) failing`);
34
+ }
35
+
36
+ if (deployState.status === 'fulfilled') {
37
+ const d = deployState.value.data;
38
+ if (d?.locked) warnings.push(`[FORGE] Deploy locked: ${d.reason || 'release in progress'}`);
39
+ }
40
+
41
+ if (releaseState.status === 'fulfilled') {
42
+ const rel = releaseState.value.data;
43
+ if (rel?.in_progress) warnings.push(`[FORGE] Release in progress: ${rel.version || 'unknown'}`);
44
+ }
45
+
46
+ if (warnings.length > 0) {
47
+ console.warn(warnings.join('\n'));
48
+ // Inject warnings into system prompt context
49
+ request._forgeWarnings = warnings;
50
+ }
51
+
52
+ return request;
53
+ }
54
+
55
+ async afterResponse(response) {
56
+ try {
57
+ await axios.post(`${AGENT_URL}/audit`, {
58
+ action: 'gemini_forge_response',
59
+ response: {
60
+ model: response.model,
61
+ tokens: response.usage,
62
+ timestamp: new Date().toISOString(),
63
+ },
64
+ }, { timeout: 2000 });
65
+ } catch (_) { /* silent */ }
66
+ return response;
67
+ }
68
+
69
+ async handleCommand(command, _args) {
70
+ const { execSync } = require('child_process');
71
+ const commands = {
72
+ '@forge': 'delimit status --layer=forge',
73
+ '@tests': 'delimit test --summary',
74
+ '@deploy': 'delimit deploy --status',
75
+ '@release': 'delimit release --status',
76
+ };
77
+ if (commands[command]) {
78
+ try {
79
+ return execSync(commands[command], { timeout: 10000 }).toString();
80
+ } catch (e) {
81
+ return `[FORGE] Command failed: ${e.message}`;
82
+ }
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Structured context for consensus — call this from hooks or consensus runners.
88
+ */
89
+ async getContext() {
90
+ const [tests, deploy, release] = await Promise.allSettled([
91
+ axios.get(`${AGENT_URL}/test/status`, { timeout: 3000 }),
92
+ axios.get(`${AGENT_URL}/deploy/status`, { timeout: 3000 }),
93
+ axios.get(`${AGENT_URL}/release/status`, { timeout: 3000 }),
94
+ ]);
95
+ return {
96
+ layer: 'forge',
97
+ tool: 'gemini',
98
+ tests: tests.status === 'fulfilled' ? tests.value.data : null,
99
+ deploy: deploy.status === 'fulfilled' ? deploy.value.data : null,
100
+ release: release.status === 'fulfilled' ? release.value.data : null,
101
+ };
102
+ }
103
+ }
104
+
105
+ module.exports = new DelimitGeminiForge();
106
+
107
+ if (typeof registerExtension === 'function') {
108
+ registerExtension(new DelimitGeminiForge());
109
+ }
@@ -50,7 +50,7 @@ async function ensureAgent() {
50
50
  program
51
51
  .name('delimit')
52
52
  .description('Prevent breaking API changes before they reach production')
53
- .version('2.3.0');
53
+ .version(require('../package.json').version);
54
54
 
55
55
  // Install command with modes
56
56
  program
@@ -803,7 +803,7 @@ program
803
803
  const specPath = foundSpecs[0];
804
804
  console.log(` Detected spec: ${chalk.bold(specPath)}`);
805
805
  console.log('');
806
- console.log(chalk.bold(' Add this to .github/workflows/api-governance.yml:\n'));
806
+ console.log(chalk.bold(' Workflow template:\n'));
807
807
  console.log(chalk.gray(` name: API Governance
808
808
  on:
809
809
  pull_request:
@@ -827,6 +827,48 @@ program
827
827
  new_spec: ${specPath}
828
828
  mode: advisory`));
829
829
  console.log('');
830
+
831
+ // Auto-write the workflow file
832
+ const workflowDir = path.join(process.cwd(), '.github', 'workflows');
833
+ const workflowFile = path.join(workflowDir, 'api-governance.yml');
834
+
835
+ if (!fs.existsSync(workflowFile)) {
836
+ try {
837
+ fs.mkdirSync(workflowDir, { recursive: true });
838
+ const workflowContent = `name: API Governance
839
+ on:
840
+ pull_request:
841
+ paths:
842
+ - '${specPath}'
843
+
844
+ permissions:
845
+ contents: read
846
+ pull-requests: write
847
+
848
+ jobs:
849
+ api-governance:
850
+ runs-on: ubuntu-latest
851
+ steps:
852
+ - uses: actions/checkout@v4
853
+ - uses: actions/checkout@v4
854
+ with:
855
+ ref: \${{ github.event.pull_request.base.sha }}
856
+ path: _base
857
+ - uses: delimit-ai/delimit@v1
858
+ with:
859
+ old_spec: _base/${specPath}
860
+ new_spec: ${specPath}
861
+ mode: advisory
862
+ `;
863
+ fs.writeFileSync(workflowFile, workflowContent);
864
+ console.log(chalk.green(` Created .github/workflows/api-governance.yml\n`));
865
+ } catch (err) {
866
+ console.log(chalk.yellow(` Could not write workflow file: ${err.message}`));
867
+ console.log(chalk.bold(' Add this to .github/workflows/api-governance.yml manually (shown above)\n'));
868
+ }
869
+ } else {
870
+ console.log(chalk.yellow(' .github/workflows/api-governance.yml already exists — skipped\n'));
871
+ }
830
872
  } else {
831
873
  console.log(' No OpenAPI spec file detected.');
832
874
  console.log(` Delimit also supports ${chalk.bold('Zero-Spec Mode')} — run ${chalk.bold('delimit lint')} in a FastAPI/NestJS/Express project.`);
@@ -950,7 +992,7 @@ program
950
992
  program
951
993
  .command('lint [old_spec] [new_spec]')
952
994
  .description('Lint API specs for breaking changes and policy violations')
953
- .option('-p, --policy <file>', 'Custom policy file')
995
+ .option('-p, --policy <preset|file>', 'Policy preset (strict/default/relaxed) or file path')
954
996
  .option('--current-version <ver>', 'Current API version for semver bump')
955
997
  .option('-n, --name <name>', 'API name for context')
956
998
  .option('--json', 'Output raw JSON')
@@ -1000,9 +1042,21 @@ program
1000
1042
  { policy: options.policy, version: options.currentVersion, name: options.name }
1001
1043
  );
1002
1044
  } else {
1045
+ const resolvedOld = path.resolve(oldSpec);
1046
+ const resolvedNew = path.resolve(newSpec);
1047
+ if (!fs.existsSync(resolvedOld)) {
1048
+ console.error(chalk.red(`\n File not found: ${resolvedOld}\n`));
1049
+ process.exit(1);
1050
+ return;
1051
+ }
1052
+ if (!fs.existsSync(resolvedNew)) {
1053
+ console.error(chalk.red(`\n File not found: ${resolvedNew}\n`));
1054
+ process.exit(1);
1055
+ return;
1056
+ }
1003
1057
  result = apiEngine.lint(
1004
- path.resolve(oldSpec),
1005
- path.resolve(newSpec),
1058
+ resolvedOld,
1059
+ resolvedNew,
1006
1060
  { policy: options.policy, version: options.currentVersion, name: options.name }
1007
1061
  );
1008
1062
  }
@@ -1068,10 +1122,11 @@ program
1068
1122
  .option('--json', 'Output raw JSON')
1069
1123
  .action(async (oldSpec, newSpec, options) => {
1070
1124
  try {
1071
- const result = apiEngine.diff(
1072
- path.resolve(oldSpec),
1073
- path.resolve(newSpec)
1074
- );
1125
+ const resolvedOld = path.resolve(oldSpec);
1126
+ const resolvedNew = path.resolve(newSpec);
1127
+ if (!fs.existsSync(resolvedOld)) { console.error(chalk.red(`\n File not found: ${resolvedOld}\n`)); process.exit(1); return; }
1128
+ if (!fs.existsSync(resolvedNew)) { console.error(chalk.red(`\n File not found: ${resolvedNew}\n`)); process.exit(1); return; }
1129
+ const result = apiEngine.diff(resolvedOld, resolvedNew);
1075
1130
 
1076
1131
  if (options.json) {
1077
1132
  console.log(JSON.stringify(result, null, 2));
@@ -1102,9 +1157,13 @@ program
1102
1157
  .option('--json', 'Output raw JSON')
1103
1158
  .action(async (oldSpec, newSpec, options) => {
1104
1159
  try {
1160
+ const resolvedOld = path.resolve(oldSpec);
1161
+ const resolvedNew = path.resolve(newSpec);
1162
+ if (!fs.existsSync(resolvedOld)) { console.error(chalk.red(`\n File not found: ${resolvedOld}\n`)); process.exit(1); return; }
1163
+ if (!fs.existsSync(resolvedNew)) { console.error(chalk.red(`\n File not found: ${resolvedNew}\n`)); process.exit(1); return; }
1105
1164
  const result = apiEngine.explain(
1106
- path.resolve(oldSpec),
1107
- path.resolve(newSpec),
1165
+ resolvedOld,
1166
+ resolvedNew,
1108
1167
  {
1109
1168
  template: options.template,
1110
1169
  oldVersion: options.oldVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "Prevent breaking API changes before they reach production. Deterministic diff engine + policy enforcement + semver classification.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "install-mcp": "bash ./hooks/install-hooks.sh mcp-only",
13
13
  "test-mcp": "bash ./hooks/install-hooks.sh troubleshoot",
14
14
  "fix-mcp": "bash ./hooks/install-hooks.sh fix-mcp",
15
- "test": "echo 'Governance is context-aware' && exit 0"
15
+ "test": "node --test tests/cli.test.js"
16
16
  },
17
17
  "keywords": [
18
18
  "openapi",
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Delimit CLI Tests
3
+ *
4
+ * Uses Node.js built-in test runner (node:test).
5
+ * Run with: node --test tests/cli.test.js
6
+ */
7
+
8
+ const { describe, it, before, after } = require('node:test');
9
+ const assert = require('node:assert/strict');
10
+ const { execSync } = require('node:child_process');
11
+ const path = require('node:path');
12
+ const fs = require('node:fs');
13
+ const os = require('node:os');
14
+
15
+ const CLI = path.join(__dirname, '..', 'bin', 'delimit-cli.js');
16
+ const FIXTURES = path.join(__dirname, 'fixtures');
17
+ const SPEC_CLEAN = path.join(FIXTURES, 'openapi.yaml');
18
+ const SPEC_BREAKING = path.join(FIXTURES, 'openapi-changed.yaml');
19
+
20
+ /**
21
+ * Run the CLI and return { stdout, stderr, exitCode }.
22
+ * Does not throw on non-zero exit.
23
+ */
24
+ function run(args, opts = {}) {
25
+ const cwd = opts.cwd || os.tmpdir();
26
+ try {
27
+ const stdout = execSync(`node "${CLI}" ${args}`, {
28
+ cwd,
29
+ encoding: 'utf-8',
30
+ timeout: 30000,
31
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
32
+ });
33
+ return { stdout, stderr: '', exitCode: 0 };
34
+ } catch (err) {
35
+ return {
36
+ stdout: err.stdout || '',
37
+ stderr: err.stderr || '',
38
+ exitCode: err.status ?? 1,
39
+ };
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // 1. CLI entry point loads without error
45
+ // ---------------------------------------------------------------------------
46
+ describe('CLI entry point', () => {
47
+ it('loads the module without throwing', () => {
48
+ assert.doesNotThrow(() => {
49
+ execSync(`node --check "${CLI}"`, { encoding: 'utf-8' });
50
+ });
51
+ });
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // 2. --version returns the version from package.json
56
+ // ---------------------------------------------------------------------------
57
+ describe('--version', () => {
58
+ it('prints the version from package.json', () => {
59
+ const pkg = require(path.join(__dirname, '..', 'package.json'));
60
+ const { stdout } = run('--version');
61
+ assert.equal(stdout.trim(), pkg.version);
62
+ });
63
+ });
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // 3. --help works at top level
67
+ // ---------------------------------------------------------------------------
68
+ describe('--help (top level)', () => {
69
+ it('shows usage and lists commands', () => {
70
+ const { stdout } = run('--help');
71
+ assert.match(stdout, /Usage:/);
72
+ assert.match(stdout, /init/);
73
+ assert.match(stdout, /lint/);
74
+ assert.match(stdout, /diff/);
75
+ assert.match(stdout, /explain/);
76
+ assert.match(stdout, /doctor/);
77
+ });
78
+ });
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // 4. Each command has help text
82
+ // ---------------------------------------------------------------------------
83
+ describe('subcommand --help', () => {
84
+ for (const cmd of ['init', 'lint', 'diff', 'explain', 'doctor']) {
85
+ it(`"help ${cmd}" shows description`, () => {
86
+ const { stdout } = run(`help ${cmd}`);
87
+ assert.match(stdout, /Usage:/);
88
+ assert.match(stdout, new RegExp(cmd));
89
+ });
90
+ }
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // 5. init creates .delimit/policies.yml with default preset
95
+ // ---------------------------------------------------------------------------
96
+ describe('init command', () => {
97
+ let tmpDir;
98
+
99
+ before(() => {
100
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-'));
101
+ });
102
+
103
+ after(() => {
104
+ fs.rmSync(tmpDir, { recursive: true, force: true });
105
+ });
106
+
107
+ it('creates .delimit/policies.yml with default preset', () => {
108
+ const { stdout, exitCode } = run('init', { cwd: tmpDir });
109
+ assert.equal(exitCode, 0);
110
+ const policyPath = path.join(tmpDir, '.delimit', 'policies.yml');
111
+ assert.ok(fs.existsSync(policyPath), 'policies.yml should exist');
112
+ const content = fs.readFileSync(policyPath, 'utf-8');
113
+ assert.match(content, /Delimit Policy Preset: default/);
114
+ assert.match(content, /override_defaults: false/);
115
+ });
116
+
117
+ it('reports already initialized on second run', () => {
118
+ const { stdout, exitCode } = run('init', { cwd: tmpDir });
119
+ assert.equal(exitCode, 0);
120
+ assert.match(stdout, /Already initialized/);
121
+ });
122
+ });
123
+
124
+ describe('init --preset strict', () => {
125
+ let tmpDir;
126
+
127
+ before(() => {
128
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-strict-'));
129
+ });
130
+
131
+ after(() => {
132
+ fs.rmSync(tmpDir, { recursive: true, force: true });
133
+ });
134
+
135
+ it('creates policies.yml with strict preset', () => {
136
+ const { stdout, exitCode } = run('init --preset strict', { cwd: tmpDir });
137
+ assert.equal(exitCode, 0);
138
+ const content = fs.readFileSync(
139
+ path.join(tmpDir, '.delimit', 'policies.yml'),
140
+ 'utf-8'
141
+ );
142
+ assert.match(content, /Delimit Policy Preset: strict/);
143
+ assert.match(content, /no_endpoint_removal/);
144
+ });
145
+ });
146
+
147
+ describe('init --preset relaxed', () => {
148
+ let tmpDir;
149
+
150
+ before(() => {
151
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'delimit-test-init-relaxed-'));
152
+ });
153
+
154
+ after(() => {
155
+ fs.rmSync(tmpDir, { recursive: true, force: true });
156
+ });
157
+
158
+ it('creates policies.yml with relaxed preset', () => {
159
+ const { stdout, exitCode } = run('init --preset relaxed', { cwd: tmpDir });
160
+ assert.equal(exitCode, 0);
161
+ const content = fs.readFileSync(
162
+ path.join(tmpDir, '.delimit', 'policies.yml'),
163
+ 'utf-8'
164
+ );
165
+ assert.match(content, /Delimit Policy Preset: relaxed/);
166
+ assert.match(content, /warn_endpoint_removal/);
167
+ });
168
+ });
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // 6. lint with identical specs returns clean (exit 0)
172
+ // ---------------------------------------------------------------------------
173
+ describe('lint (clean)', () => {
174
+ it('returns exit 0 with no violations for identical specs', () => {
175
+ const { stdout, exitCode } = run(
176
+ `lint "${SPEC_CLEAN}" "${SPEC_CLEAN}" --json`
177
+ );
178
+ assert.equal(exitCode, 0);
179
+ const result = JSON.parse(stdout);
180
+ assert.equal(result.decision, 'pass');
181
+ assert.equal(result.exit_code, 0);
182
+ assert.equal(result.summary.breaking_changes, 0);
183
+ assert.equal(result.violations.length, 0);
184
+ });
185
+ });
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // 7. lint with breaking change returns exit 1
189
+ // ---------------------------------------------------------------------------
190
+ describe('lint (breaking)', () => {
191
+ it('returns exit 1 when breaking changes are detected', () => {
192
+ const { stdout, exitCode } = run(
193
+ `lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
194
+ );
195
+ assert.equal(exitCode, 1);
196
+ const result = JSON.parse(stdout);
197
+ assert.equal(result.decision, 'fail');
198
+ assert.equal(result.exit_code, 1);
199
+ assert.ok(result.summary.breaking_changes > 0, 'should have breaking changes');
200
+ assert.ok(result.violations.length > 0, 'should have violations');
201
+ });
202
+
203
+ it('includes endpoint_removed in changes', () => {
204
+ const { stdout } = run(
205
+ `lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
206
+ );
207
+ const result = JSON.parse(stdout);
208
+ const types = result.all_changes.map(c => c.type);
209
+ assert.ok(types.includes('endpoint_removed'), 'should detect endpoint removal');
210
+ });
211
+
212
+ it('includes semver bump classification', () => {
213
+ const { stdout } = run(
214
+ `lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
215
+ );
216
+ const result = JSON.parse(stdout);
217
+ assert.ok(result.semver, 'should have semver field');
218
+ assert.equal(result.semver.bump, 'major');
219
+ });
220
+ });
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // 8. diff outputs change types
224
+ // ---------------------------------------------------------------------------
225
+ describe('diff command', () => {
226
+ it('outputs changes between two specs', () => {
227
+ const { stdout, exitCode } = run(
228
+ `diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
229
+ );
230
+ assert.equal(exitCode, 0);
231
+ const result = JSON.parse(stdout);
232
+ assert.ok(result.total_changes > 0, 'should have changes');
233
+ assert.ok(result.breaking_changes > 0, 'should have breaking changes');
234
+ assert.ok(Array.isArray(result.changes), 'changes should be an array');
235
+ });
236
+
237
+ it('reports change types correctly', () => {
238
+ const { stdout } = run(
239
+ `diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
240
+ );
241
+ const result = JSON.parse(stdout);
242
+ const types = result.changes.map(c => c.type);
243
+ assert.ok(types.includes('endpoint_removed'));
244
+ assert.ok(types.includes('type_changed'));
245
+ assert.ok(types.includes('enum_value_removed'));
246
+ });
247
+
248
+ it('marks breaking changes with is_breaking flag', () => {
249
+ const { stdout } = run(
250
+ `diff "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
251
+ );
252
+ const result = JSON.parse(stdout);
253
+ const breakingChanges = result.changes.filter(c => c.is_breaking);
254
+ assert.equal(breakingChanges.length, result.breaking_changes);
255
+ });
256
+
257
+ it('returns no changes for identical specs', () => {
258
+ const { stdout, exitCode } = run(
259
+ `diff "${SPEC_CLEAN}" "${SPEC_CLEAN}" --json`
260
+ );
261
+ assert.equal(exitCode, 0);
262
+ const result = JSON.parse(stdout);
263
+ assert.equal(result.total_changes, 0);
264
+ assert.equal(result.breaking_changes, 0);
265
+ });
266
+ });
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // 9. explain command
270
+ // ---------------------------------------------------------------------------
271
+ describe('explain command', () => {
272
+ it('generates human-readable explanation', () => {
273
+ const { stdout, exitCode } = run(
274
+ `explain "${SPEC_CLEAN}" "${SPEC_BREAKING}" --json`
275
+ );
276
+ assert.equal(exitCode, 0);
277
+ const result = JSON.parse(stdout);
278
+ assert.ok(result.output, 'should have output text');
279
+ assert.ok(result.output.length > 0, 'output should not be empty');
280
+ assert.ok(result.template, 'should report template used');
281
+ });
282
+
283
+ it('supports --template flag', () => {
284
+ const { stdout, exitCode } = run(
285
+ `explain "${SPEC_CLEAN}" "${SPEC_BREAKING}" --template migration --json`
286
+ );
287
+ assert.equal(exitCode, 0);
288
+ const result = JSON.parse(stdout);
289
+ assert.equal(result.template, 'migration');
290
+ });
291
+ });
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // 10. lint with --policy preset
295
+ // ---------------------------------------------------------------------------
296
+ describe('lint --policy', () => {
297
+ it('accepts relaxed preset and does not fail on breaking changes', () => {
298
+ // relaxed preset uses action:warn, so decision is "warn" not "fail"
299
+ const { stdout, exitCode } = run(
300
+ `lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --policy relaxed --json`
301
+ );
302
+ assert.equal(exitCode, 0, 'relaxed preset should exit 0');
303
+ const result = JSON.parse(stdout);
304
+ assert.notEqual(result.decision, 'fail', 'relaxed should not produce fail decision');
305
+ });
306
+
307
+ it('accepts strict preset and fails on breaking changes', () => {
308
+ const { stdout, exitCode } = run(
309
+ `lint "${SPEC_CLEAN}" "${SPEC_BREAKING}" --policy strict --json`
310
+ );
311
+ assert.equal(exitCode, 1);
312
+ const result = JSON.parse(stdout);
313
+ assert.equal(result.decision, 'fail');
314
+ assert.ok(result.violations.length > 0);
315
+ });
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // 11. Error handling -- missing files
320
+ // ---------------------------------------------------------------------------
321
+ describe('error handling', () => {
322
+ it('lint with nonexistent spec file reports error', () => {
323
+ const { exitCode } = run('lint /nonexistent/old.yaml /nonexistent/new.yaml --json');
324
+ assert.notEqual(exitCode, 0);
325
+ });
326
+
327
+ it('diff with nonexistent spec file reports error', () => {
328
+ const { exitCode } = run('diff /nonexistent/old.yaml /nonexistent/new.yaml --json');
329
+ assert.notEqual(exitCode, 0);
330
+ });
331
+ });
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // 12. api-engine module exports
335
+ // ---------------------------------------------------------------------------
336
+ describe('api-engine module', () => {
337
+ it('exports lint, diff, explain, semver, zeroSpec functions', () => {
338
+ const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
339
+ assert.equal(typeof engine.lint, 'function');
340
+ assert.equal(typeof engine.diff, 'function');
341
+ assert.equal(typeof engine.explain, 'function');
342
+ assert.equal(typeof engine.semver, 'function');
343
+ assert.equal(typeof engine.zeroSpec, 'function');
344
+ });
345
+
346
+ it('lint returns parsed JSON with decision field', () => {
347
+ const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
348
+ const result = engine.lint(SPEC_CLEAN, SPEC_CLEAN);
349
+ assert.ok(result.decision, 'should have decision field');
350
+ assert.equal(result.decision, 'pass');
351
+ });
352
+
353
+ it('diff returns parsed JSON with changes array', () => {
354
+ const engine = require(path.join(__dirname, '..', 'lib', 'api-engine.js'));
355
+ const result = engine.diff(SPEC_CLEAN, SPEC_BREAKING);
356
+ assert.ok(Array.isArray(result.changes), 'should have changes array');
357
+ assert.ok(result.total_changes > 0);
358
+ });
359
+ });
@@ -0,0 +1,56 @@
1
+ openapi: "3.0.3"
2
+ info:
3
+ title: Pet Store API
4
+ version: "2.0.0"
5
+ description: Sample API for Delimit quickstart
6
+ paths:
7
+ /pets:
8
+ get:
9
+ summary: List all pets
10
+ operationId: listPets
11
+ parameters:
12
+ - name: limit
13
+ in: query
14
+ required: false
15
+ schema:
16
+ type: integer
17
+ format: int32
18
+ responses:
19
+ "200":
20
+ description: A list of pets
21
+ content:
22
+ application/json:
23
+ schema:
24
+ type: array
25
+ items:
26
+ $ref: "#/components/schemas/Pet"
27
+ post:
28
+ summary: Create a pet
29
+ operationId: createPet
30
+ requestBody:
31
+ required: true
32
+ content:
33
+ application/json:
34
+ schema:
35
+ $ref: "#/components/schemas/Pet"
36
+ responses:
37
+ "201":
38
+ description: Pet created
39
+ # /pets/{petId} removed -- this is the breaking change
40
+ components:
41
+ schemas:
42
+ Pet:
43
+ type: object
44
+ required:
45
+ - id
46
+ - name
47
+ properties:
48
+ id:
49
+ type: integer
50
+ name:
51
+ type: string
52
+ status:
53
+ type: string
54
+ enum:
55
+ - available
56
+ - adopted