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.
- package/CHANGELOG.md +33 -0
- package/README.md +81 -109
- package/adapters/codex-forge.js +107 -0
- package/adapters/codex-jamsons.js +142 -0
- package/adapters/codex-security.js +94 -0
- package/adapters/gemini-forge.js +109 -0
- package/bin/delimit-cli.js +70 -11
- package/package.json +2 -2
- package/tests/cli.test.js +359 -0
- package/tests/fixtures/openapi-changed.yaml +56 -0
- package/tests/fixtures/openapi.yaml +87 -0
|
@@ -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
|
+
}
|
package/bin/delimit-cli.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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>', '
|
|
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
|
-
|
|
1005
|
-
|
|
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
|
|
1072
|
-
|
|
1073
|
-
|
|
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
|
-
|
|
1107
|
-
|
|
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
|
+
"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": "
|
|
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
|